Testing multiple implementations of an interface in jest
October 2, 2022
TypeScript interfaces are not same as Java or PHP interfaces but they can be used in a similar way.
You can use an interface
in TypeScript to validate that a class
has certain methods. Let's say we are building an invoicing applications. We need a way to persist and retrieve invoices. We can use an interface
to describe this:
class Invoice {
// ...
}
interface Invoices {
getById(id: string): Promise<Invoice | undefined>;
persist(invoice: Invoice): Promise<void>;
}
New developers often start with designing the database schema. I strongly recommend starting from the other side.
Let's start with an interface and start designing our model first (often guided by tests). We can start thinking about an actual implementation of Invoices
when we know what information an Invoice
will hold and how we need to retrieve invoices.
To avoid mocking and keep our tests snappy, we can add a memory implementation:
class InvoicesInMemory implements Invoices {
private invoices: Map<string, Invoice> = new Map();
getById(id: string): Promise<Invoice | undefined> {
// ...
}
persist(invoice: Invoice): Promise<void> {
// ...
}
}
Normally we would write our tests as follows:
describe("InvoicesInMemory", () => {
test("it works", async () => {
const invoices = new InvoicesInMemory();
expect(await invoices.getById("unknown")).toEqual(undefined);
});
});
The tests verify the actual behaviour of the interface. Let's say we add a database implementation:
class InvoicesDatabase implements Invoices {
constructor(private readonly db: Database) {}
// ...
}
Do we just copy the same tests? That's not a good idea because our tests might get out of sync if we forget to apply the same changes to other tests.
Here's a better way:
function getTests(instance: () => Invoices) {
test("it works", async () => {
const invoices = instance();
expect(await invoices.getById("unknown")).toEqual(undefined);
});
}
describe("InvoicesInMemory", () => {
getTests(() => new InvoicesInMemory());
});
We extract the tests to a function and add a function to create an instance of the subject we want to test (in this case Invoices
).
When we want to test another implementations, we can call the getTests
function again and pass in a different instance:
describe("InvoicesDatabase", () => {
let db: Database;
beforeEach(async () => {
db = getTestDatabase();
await db.query("TRUNCATE invoices");
conn.release();
});
getTests(() => new InvoicesDatabase(factory));
afterEach(async () => {
await db.close();
});
});
Note that you can still use beforeEach
and afterEach
to setup a database connection and close it after the test.