My understanding of TDD
TDD is a practice that follows red-green-refactor circles:
- Red: write a minimal and straightforward test first, one at a time. The test fails hence the name 'red', because the code is not written yet.
- Green: write just enough code to get the test, be it a shortcut that doesn't make any business sense, for example, hard coding a string. The aim here is to get the test to pass i.e. turning green.
- Refactor: refactor both tests and code and keep the test green
- Repeat with more tests, one at a time, as simple as possible, edging closer to the business requirements
TDD requires an accurate understanding of business requirements. TDD is the process of constantly expressing business requirements in the form of tests, one small step at a time.
TDD is the ultimate weapon for highly maintainable software. However, from what I learned so far, it is not practical to apply TDD to all codes or you will get burned and give up.
The most effective way is to separate the business logic from other noises to become an input/output based structure. TDD the business logic.
Testing is hard. Sticking to testing is all about forcing ourselves to design our code in such a way that testing is not hard anymore. It is the design that is hard.
What is publish-subscribe pattern
Definition from Wikipedia:
publish–subscribe is a messaging pattern where senders of messages, called publishers, do not program the messages to be sent directly to specific receivers, called subscribers, but instead categorize published messages into classes without knowledge of which subscribers, if any, there may be.
Similarly, subscribers express interest in one or more classes and only receive messages that are of interest, without knowledge of which publishers, if any, there are.
The essence of the pattern is the decoupling of publishers and subscribers.
This blog post will show an attempt to implement a topic-based publish-subscribe pattern using Typescript and Jest.
Behaviours
- publishers can send messages with a topic and payload
- subscribers can subscribe to any topics and provide a call back function
- subscribers' call back function gets called with the payload if any publishers publish any of the subscribed topics
Constrains
Publishers and subscribers do not know the existence of each other
Code
import { Container } from './pub-sub'; | |
import { Publisher } from './publisher'; | |
import { Subscriber } from './subscriber'; | |
type Data = { content: string }; | |
describe('Publisher/Subscribe TDD', function () { | |
// Below tests in the `describe` block are the left-over TDD tests | |
// They can be commented out because they became implementation details when the pattern is established | |
describe('Broker', function () { | |
it('should provide publish method for publishers to publish their messages', function () { | |
Container.broker.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
}); | |
it('should provide subscribe method for subscriber to receive messages', function () { | |
const callback = jest.fn(); | |
Container.broker.subscribe('Test', callback); | |
}); | |
it('should pass data from publisher to subscriber', function () { | |
const broker = Container.broker; | |
const callback = jest.fn(); | |
broker.subscribe('Test', callback); | |
broker.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content' }); | |
}); | |
it('should not pass data to subscriber that not subscribing the topic ', function () { | |
const broker = Container.broker; | |
const callback = jest.fn(); | |
broker.subscribe('Test', callback); | |
broker.publish({ topic: 'Test2', data: { content: 'Test content' } }); | |
expect(callback).not.toHaveBeenCalled(); | |
}); | |
it('should pass data to multiple subscribers', function () { | |
const broker = Container.broker; | |
const callback1 = jest.fn(); | |
const callback2 = jest.fn(); | |
broker.subscribe('Test', callback1); | |
broker.subscribe('Test', callback2); | |
broker.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback1).toHaveBeenCalledWith({ content: 'Test content' }); | |
expect(callback2).toHaveBeenCalledWith({ content: 'Test content' }); | |
}); | |
it('should provide unsubscribe method for subscribers to unsubscribe', function () { | |
const broker = Container.broker; | |
const callback = jest.fn(); | |
const unsubscribe = broker.subscribe('Test', callback); | |
broker.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content' }); | |
callback.mockClear(); | |
unsubscribe(); | |
broker.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback).not.toHaveBeenCalledWith({ content: 'Test content' }); | |
}); | |
describe('Publisher', function () { | |
it('should publish message to a broker', function () { | |
const publisher = new Publisher(); | |
jest.spyOn(Container.broker, 'publish'); | |
publisher.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(Container.broker.publish).toHaveBeenCalledWith({ | |
topic: 'Test', | |
data: { content: 'Test content' }, | |
}); | |
}); | |
}); | |
describe('Subscriber', function () { | |
it('should subscribe to a broker', function () { | |
const subscriber = new Subscriber<{ conetent: string }>(); | |
jest.spyOn(Container.broker, 'subscribe'); | |
subscriber.subscribe('Test', (_) => undefined); | |
expect(Container.broker.subscribe).toHaveBeenCalledWith('Test', expect.any(Function)); | |
}); | |
}); | |
}); | |
describe('Putting publisher and subscriber together', function () { | |
it('should receive message from publisher and pass data to subscribers ', function () { | |
const publisher = new Publisher<Data>(); | |
const subscriber = new Subscriber<Data>(); | |
const callback = jest.fn(); | |
subscriber.subscribe('Test', callback); | |
publisher.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content' }); | |
}); | |
it('should receive message from publisher and send data to multiple subscribers', function () { | |
const publisher = new Publisher<Data>(); | |
const subscriber = new Subscriber<Data>(); | |
const subscriber1 = new Subscriber<Data>(); | |
const callback = jest.fn(); | |
const callback1 = jest.fn(); | |
subscriber.subscribe('Test', callback); | |
subscriber1.subscribe('Test', callback1); | |
publisher.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content' }); | |
expect(callback1).toHaveBeenCalledWith({ content: 'Test content' }); | |
}); | |
it('should receive data from multiple publishers', function () { | |
const publisher = new Publisher<Data>(); | |
const publisher1 = new Publisher<Data>(); | |
const subscriber = new Subscriber<Data>(); | |
const callback = jest.fn(); | |
subscriber.subscribe('Test', callback); | |
publisher.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
publisher1.publish({ topic: 'Test', data: { content: 'Test content from publisher 1' } }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content' }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content from publisher 1' }); | |
}); | |
it('should only receive subscribed messages', function () { | |
const publisher = new Publisher<Data>(); | |
const publisher1 = new Publisher<Data>(); | |
const subscriber = new Subscriber<Data>(); | |
const subscriber1 = new Subscriber<Data>(); | |
const callback = jest.fn(); | |
const callback1 = jest.fn(); | |
subscriber.subscribe('Test', callback); | |
subscriber1.subscribe('Test1', callback1); | |
publisher1.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
publisher.publish({ topic: 'Test1', data: { content: 'Test content 1' } }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content' }); | |
expect(callback).toHaveBeenCalledTimes(1); | |
expect(callback1).toHaveBeenCalledWith({ content: 'Test content 1' }); | |
expect(callback1).toHaveBeenCalledTimes(1); | |
}); | |
it('should unsubscribe subscriber', function () { | |
const publisher = new Publisher<Data>(); | |
const subscriber = new Subscriber<Data>(); | |
const callback = jest.fn(); | |
const unsubscribe = subscriber.subscribe('Test', callback); | |
publisher.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback).toHaveBeenCalledWith({ content: 'Test content' }); | |
callback.mockClear(); | |
unsubscribe(); | |
publisher.publish({ topic: 'Test', data: { content: 'Test content' } }); | |
expect(callback).not.toHaveBeenCalledWith({ content: 'Test content' }); | |
}); | |
}); | |
}); |
import { callAll } from './callAll'; | |
export type Message<T> = { topic: string; data: T }; | |
export type Callback<T> = (data: T) => void; | |
export interface IBroker<T> { | |
publish(message: Message<T>): void; | |
subscribe(topic: string, callback: Callback<T>): () => void; | |
} | |
class Broker<T> implements IBroker<T> { | |
private subscribers: { [topic: string]: Callback<T>[] } = {}; | |
public publish({ topic, data }: Message<T>): void { | |
callAll(...(this.subscribers[topic] ?? []))(data); | |
} | |
public subscribe(topic: string, callback: Callback<T>): () => void { | |
this.subscribers[topic] = [...(this.subscribers[topic] ?? []), callback]; | |
return () => this.unsubscribe(topic, callback); | |
} | |
private unsubscribe(topic: string, callback: Callback<T>) { | |
this.subscribers[topic] = this.subscribers[topic].filter( | |
(subscriber) => subscriber !== callback | |
); | |
} | |
} | |
export const Container: { broker: IBroker<any> } = { | |
broker: new Broker<any>(), | |
}; |
import { Container, IBroker, Message } from './pub-sub'; | |
export class Publisher<T> { | |
constructor(private broker: IBroker<T> = Container.broker) {} | |
public publish(message: Message<T>): void { | |
this.broker.publish(message); | |
} | |
} |
import { Callback, Container, IBroker } from './pub-sub'; | |
export class Subscriber<T> { | |
constructor(private broker: IBroker<T> = Container.broker) {} | |
public subscribe(topic: string, callback: Callback<T>): () => void { | |
return this.broker.subscribe(topic, callback); | |
} | |
} |
Top comments (0)