top of page
Writer's pictureShai Yallin

Using Contract Tests for reliable memory fakes

In the previous post of this series, I described a method for creating fast integrative Acceptance Tests for NestJS applications by introducing memory-based fakes for all IO operations, such as DB access, message queues or network calls. If we assume that these memory fakes conform to the same contract as the production implementations they replace, we can create a large suite of these system-wide tests, asserting that all features of the system behave as we expect them to, in a black-box manner. These tests will protect us when changing the system, as they do not rely on any internal implementation detail.


However, this assumption - that the fakes actually behave exactly like their production counterparts - has been left unproven. This post aims to describe the methodology in which we prove this assumption, with the help of Contract Tests. Note that this methodology is general to software engineering and has nothing to do with NestJS specifically. Furthermore, the tests don’t even need to use NestJS - they can simply instantiate the production and fake implementations using vanilla Javascript.


To demonstrate the methodology, we’ll go back to the e-commerce backend example from the previous post. The ProductRepository interface is implemented by two classes:

import { Collection, Db, ObjectId, WithId } from "mongodb";

const docToProduct = ({_id, ...rest}: WithId<ProductTemplate>) => 
   Product.parse({id: _id.toString(), ...rest});

@Injectable()
export class MongoDBProductRepository {
   private products: Collection<ProductTemplate>;

   constructor(@Inject("storeDB") db: Db) {
       this.products = db.collection("products");
   }

   async create(fields: ProductTemplate): Promise<Product> {
       const id = new ObjectId();
       await this.products.insertOne({_id: id, ...fields});
       return {
           id: id.toString(),
           ...fields,
       }
   }

   async findAll(): Promise<Product[]> {
       return this.products.find()
           .map(docToProduct)
           .toArray();
   }

   async findById(id: string): Promise<Product | undefined> {
       return this.products.findOne({_id: {$eq: new ObjectId(id)}})
           .then(doc => doc ? docToProduct(doc) : undefined)
   }
}

export type ProductRepository = Omit<MongoDBProductRepository, "products">;

export class InMemoryProductRepository implements ProductRepository {
   private products: Product[] = []

   constructor(products: ProductTemplate[] = []) {
       products.forEach(p => this.create(p));
   }

   async findById(id: string): Promise<Product | undefined> {
       return this.products.find(product => product.id === id);
   }


   async create(template: Omit<Product, "id">): Promise<Product> {
       const product = {...template, id: nanoid()};
       this.products.push(product);
       return product;
   }

   async findAll(): Promise<Product[]> {
       return this.products;
   }
}

To test that both implementations conform to the same contract, we’ll make use of Jest (or Vitest’s) describe.each API for running parameterized tests:

describe.each(adapters)('$name product repository', ({makeRepo}) => {
  // tests go here
});

Where adapters is an array of objects with a name and a makeRepo function, that when called, returns a Promise to a repo and a function that closes the DB connection:


const adapters = [
   {name: "mongodb", makeRepo: async () => {
       const mongo = new MongoClient(/* MONGO_URL */)
       await mongo.connect();
       const repo = new MongoDBProductRepository(mongo.db());

       return {
           repo,
           close: mongo.close.bind(mongo)
       }
   }},
   {name: "memory", makeRepo: async () => ({
       repo: new InMemoryProductRepository(),
       close: () => {},
   })}
];

We can now proceed to write tests for the ProductRepository interface. Note that these tests must be black-box, assuming nothing about the implementation, which means we must test the repository solely by using its public interface. This is actually a good thing, because we only care about what it returns and not how it returns it:

describe.each(adapters)('$name product repository', ({makeRepo}) => {
   it('finds product by id', async () => {
       const { repo, close } = await makeRepo();

       const p = await repo.create(aProduct());
       await expect(repo.findById(p.id)).resolves.toEqual(p);

       return close();
   });

   it('finds all products', async () => {
       const { repo, close } = await makeRepo();

       const p1 = await repo.create(aProduct());
       const p2 = await repo.create(aProduct());
       const p3 = await repo.create(aProduct());

       const found = await repo.findAll();
       expect(found).toContainEqual(p1);
       expect(found).toContainEqual(p2);
       expect(found).toContainEqual(p3);

       return close();
   });
})

Note that the test for findAll doesn’t assert that only 3 products have been returned - because we can’t assume that the database was empty prior to running the test. If we want to be able to make this assumption, we could, for instance, randomize a dbName and pass it to the call to mongo.db():

const mongo = await new MongoClient(/* MONGO_URL */);
await mongo.connect();
const dbName = aRandomString();
const repo = new MongoDBProductRepository(mongo.db(dbName));
return {
  repo,
  close: mongo.close.bind(mongo)
}

From this point on, we can use TDD if and when we need to drive the adding of new behavior to the ProductRepository interface. For instance, maybe we want to add support for search by product title:

it('finds products matching a title query', async () => {
   const { repo, close } = await makeRepo();

   const p1 = await repo.create(aProduct({title: "foo"}));
   const p2 = await repo.create(aProduct({title: "bar"}));
   const p3 = await repo.create(aProduct({title: "food"}));

   const found = await repo.findByTitle("foo");
   expect(found)
.toContainEqual(p1);
   expect(found).not.toContainEqual(p2);
   expect(found).toContainEqual(p3);

   return close();
});

The test will not compile because findByTitle() hasn’t been added to the interface yet. We can now proceed to add it to both implementations:

export class MongoDBProductRepository {
   …

   async findByTitle(search: string): Promise<Product[]> {
       return this.products.find({
         title: {$regex: search, $options: "i"}
       }).map(docToProduct).toArray();
   }
}

export class InMemoryProductRepository {
   …

   async findByTitle(search: string): Promise<Product[]> {
       return this.products.filter(({title}) => 
         title.includes(search)
       );
   }
}

An alternate approach would be to drive the requirement via acceptance tests; this approach is preferable in situations where we’re not sure yet how our adapter’s API should look, as it allows us to first write the code that uses it, and only then implement it. We write an acceptance test for the new search API which isn’t implemented yet, and then we implement it by writing the business logic and implementing the findByTitle() function in the memory fake only. After we’re satisfied that this is the API we really need, we can add a contract test requiring this behavior also from the production implementation, and finally we implement it using MongoDB. 


Since the contract test needs an instance of MongoDB running locally, we will run it in CI and locally against a running Docker container. In a system that employs multiple 3rd parties, we will typically maintain a Docker Compose file that would start all 3rd parties and bind their ports to localhost.


By following the methodology of writing memory-based acceptance tests along with a concise suite of contract tests, we can achieve a level of confidence hitherto only available via E2E testing, while keeping the majority of our test suite fast and deterministic. In the next post, I will discuss strategies for effectively testing NestJS microservices and their integration.

24 views0 comments

Comments


NEED MY HELP?

Feeling stuck? dealing with growing pains? I can help.  

I'll reach out as soon as I can

bottom of page