Back to list

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.