Skip to content

Week 05 · Testing ​

Last week we looked at a semi-standard layered architecture for building RESTful APIs. We build a couple of routes, top to bottom, and wrote a few e2e tests to make sure they worked as designed. There was a bit of last-minute chaos in which I came up with a new Now You Try on the fly and then when I tried to solve it I found a bug in my earlier lecture code. What fun!

We're going to pick up where we left off--today's starter code is the completed lecture code with all of the NYT solutions. However, today's starter looks a bit different from last week's code because I decided to clean up a few things I wasn't happy with and also to make sure that the lecture is more in alignment with what you'll see in HW2.

This week's goals ​

  • Revisit REST architecture with a bit of conceptual cleanup
  • Get hands-on practice with mongodb-memory-server (MMS)
  • Get started with unit testing using mocks and spies

Before anything else ​

Accept the Week05 NYT Assignment, clone the resulting repo, then:

bash
npm install
npm test

If this is the first time you're running a test with MMS, it could take a bit of time. MMS downloads a full install of MongoDB Community Edition and stores it in an obscure location on your computer. This only happens the first time you run it so subsequent runs will be much faster.

You should see passing tests and four marked todo.

The app you've been given ​

text
src/
├── db/            db.ts, products-repository.ts
├── models/        product.ts
├── services/      products-service.ts
├── controllers/   products-controller.ts
├── routes/        products-router.ts
├── middleware/    error-handler.ts
├── __tests__/     products.e2e.test.ts, test-products.ts
├── errors.ts
├── mocks-and-spies.unit.test.ts
├── app.ts
└── index.ts

This repo contains a completed API for the full set of /products endpoints. Most of this is what was written last week, but there are a few new pieces--most notably the PATCH route that we will walk through and then write tests for. Also some of the file and functions names are different because I wasn't crazy with some of my choices, and I've cleaned up my thinking about the db and service layers, which is reflected in both naming and how I am thinking about the roles of these layers. The changes will show up as we walk through the layers.

Following one request ​

We will follow the PATCH /products/:id route from top to bottom and then back up to the top again. At each layer, we'll ask what job that layer is supposed to do and what guarantees it makes to the layers above and/or below it.

The router ​

In routes/products-router.ts:

ts
import express from 'express';
import * as productsController from '../controllers/products-controller.js';

export const productsRouter = express.Router();

productsRouter.get('/', productsController.getProducts);
productsRouter.get('/:id', productsController.getProduct);
productsRouter.post('/', productsController.postProduct);
productsRouter.patch('/:id', productsController.patchProduct);
productsRouter.delete('/:id', productsController.deleteProduct);

This is generally the thinnest layer with a very simple job--make sure the correct HTTP request gets routed to the correct controller and handler method.

Guarantee: PATCH /products/:id reaches patchProduct, and nothing else does.

Names and roles

No real change here

The controller ​

In controllers/products-controller.ts:

ts
export const patchProduct = async (
  req: Request,
  res: Response
): Promise<void> => {
  const product = await productsService.updateProduct(
    String(req.params.id),
    req.body
  );
  if (!product) {
    res.status(404).json({ error: 'No product with that id' });
    return;
  }
  res.json(product);
};

The job of the controller layer is to actually handle the HTTP request by invoking whatever service methods and/or data access functions are needed and assembling the results into a response to send back to the client. This layer also determines which HTTP status code to send with the response, depending on how things went during the attempt to handle it. Nothing below the controller layer knows anything about HTTP.

Guarantee: 404 for an id that isn't there, 200 and the updated product otherwise.

Names and roles

Cleaned up singular/plural naming. Folder is "controllers" because it will hold a controller for each route. The files are named <route>-controller.ts, so this one is products-controller.ts.

Also function names now use HTTP verbs where it's reasonable, since they are effectively handlers for specific endpoint/verb pairs, so we get getProducts(), postProducts(), etc.

The service ​

In services/products-service.ts:

ts
export const updateProduct = async (
  id: string,
  changes: ProductFields
): Promise<Product | null> => {
  const existing = await productsRepository.findProduct(id);
  if (!existing) {
    return null;
  }

  const quantity = changes.quantity ?? existing.quantity;
  const price = changes.price ?? existing.price;

  if (quantity < 0) {
    throw new ValidationError('quantity cannot be negative');
  }
  if (price < 0) {
    throw new ValidationError('price cannot be negative');
  }

  return productsRepository.updateProduct(id, changes);
};

The service layer lives entirely within the universe of the app (this is a bit different from last week). It only knows about application objects (e.g., Products)--nothing about HTTP or Mongo. This is the layer where "business logic" lives. For example, it's the service layer that knows what the business knows and what a human would know, but what HTTP, TypeScript, and Mongo do not know, which is that the quantity of a product in inventory can never be negative, nor can its price. Can a price be exactly zero? Only the business would know whether that makes sense, and whatever decision is made would be captured and enforced by the service layer.

Note that the service method here (updateProduct()) checks if the requested operation is valid before applying the change. This is how the service can make sure that invalid data doesn't make it into the database, where it could do all kinds of damage if other operations end up accessing it.

A brief digression into error handling ​

There's another interesting thing going on here. The two validation checks for negative quantity and price both throw a ValidationError when they fail. If you look into what a ValidationError is (in VS Code you can right-click on it and select "Go to Source Definition" -- you can also look at the imports at the top), you'll see it's something that'd defined within our app and it's just a relabelling of the base JavaScript Error. Here it is, in errors.ts:

ts
export class ValidationError extends Error {}

You can then see where all it's being used (in VS Code, right-click and choose "Find All References"), you'll find that in addition to being referenced in error.ts, where it's defined, and in products-service.ts where we discovered it, it's used in the error handling middleware defined in error-handler.ts. This is the standard "fall through" error handler that runs when an error is thrown--in past weeks we always sent a 500 response, but here we're using ValidationError as a signal that a specific kind of error occurred that should result in a different status code--namely a 400 for "bad request." In middleware/error-handler.ts:

ts
import type { NextFunction, Request, Response } from 'express';
import { ValidationError } from '../errors.js';

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  if (err instanceof ValidationError) {
    res.status(400).json({ error: err.message });
    return;
  }
  res.status(500).json({ error: 'Something went wrong' });
};

Why do it this way? Well, this gives you a standard way of dealing with errors that occur below the controller layer that should result in different information being sent back to the client. An alternative would be to use a try/catch block in the controller, but you'd have to do that in every controller and include handling code for every error type that could be generated, worry about consistent handling, etc. This is one approach that might be cleaner for certain types of error handling, but it's not the only way. You'll see a different approach in HW2 (that's building on the approach from HW1 you already saw).

So anyway, back to what this service method does:

Guarantee: quantity never goes negative — and if a change would make it so, nothing is written at all.

Names and roles

This change is a bit bigger because I did some research on conventions around service and db layers after talking with José. Predictably, José was right and I was wrong. I said that the service layer is what translates from DB concepts to application (or "business") concepts, but it's more common for that to happen at the db layer.

The service layer is more commonly described as living entirely in the universe of the application (for this route, that's Products in and Products out), and as being where the "business logic" lives. So for today and going forward, that's how we're going to talk about the service layer.

In terms of names, I've gone with "CRUD-speak"--so we have get...() (because read...() just sounds too weird), create...(), update...(), etc. Note that update is also used in the db layer and delete is used in both controllers and db repositories. Oh well. Naming is hard.

The repository (a.k.a. the db layer) ​

In db/products-repository.ts:

ts
export const updateProduct = async (
  id: string,
  changes: ProductFields
): Promise<Product | null> => {
  const doc = await getDb()
    .collection(PRODUCTS)
    .findOneAndUpdate(
      { _id: new ObjectId(id) },
      { $set: changes },
      { returnDocument: 'after' }
    );
  return doc ? toProduct(doc) : null;
};

Here we see the revised db layer pattern in action--introducing the notion of "repositories" for each type of entity. In this case we see the repository for Products. The layer translates between Mongo and the application--turning documents into Products and vice versa.

In this instance, we are using the Mongo collection function findOneAndUpdate to look for the product's Document by ID and then applying an update using $set with the changed fields. After getting the modified doc back from Mongo, the updateProduct repository function turns the doc back into a product by calling toProduct().

Still in db/products-repository.ts:

ts
const toProduct = (doc: Document): Product => {
  return {
    id: doc._id.toString(),
    modelName: doc.modelName,
    modelNumber: doc.modelNumber,
    manufacturer: doc.manufacturer,
    color: doc.color,
    price: doc.price,
    quantity: doc.quantity
  };
};

This translation function was implemented in the models layer last week, but with the updated notion of what repositories do in the db layer, it makes more sense to put it here.

Guarantee: only the fields that were sent are changed; the rest are left alone. The changed Product is persisted in Mongo.

Names and roles

Arguably this is the biggest change from last week. I said that the db layer only speaks Mongo and knows nothing about application concepts like Products. It's more conventional, however, to organize the db layer into "repositories" that manage collections of application/business entities and translate from DB objects like Documents to application objects like Products. This makes it so that DB functions and types (like Document but also ObjectId and InsertOneResult) stay in one layer, which I have to admit is much cleaner than what I presented last week.\

For naming, we stick with Mongo-style names like find...(), insert...(), add...() so the layers above know what we're dealing with.

The model ​

In models/product.ts:

ts
export type Product = {
  id: string;
  modelName: string;
  modelNumber: string;
  manufacturer: string;
  color: string;
  price: number;
  quantity: number;
};

export type NewProduct = {
  modelName: string;
  modelNumber: string;
  manufacturer: string;
  color: string;
  price: number;
  quantity: number;
};

export type ProductFields = Partial<Product>;

With translation moving to the db layer, the model layer is simplified--it's just about declaring the types used in the application.

Guarantee: every layer that manages Products is working from the same definition of what a Product is. Among other things, it ensures that every Product has an id: string and not an _id: ObjectId().

Names and roles

The main thing that changed is that translation moved from the models layer to the db/repository layer. Models are just types now.

What each layer guarantees ​

LayerGuarantee
routerPATCH /products/:id reaches patchProduct
controller404 missing · 200 and the updated product otherwise
servicequantity never negative — and nothing is written if it would be
repositoryonly the fields that were sent change, change is persisted
modelsall Products have the same shape; id, not _id

And back again... ​

We followed the PATCH from the top to the bottom, but of course that's not the whole story. The result of the request needs to be communicated back up through the layers and ultimately back to the client.

To follow the lifecycle of a PATCH request back up, follow the returns. Notice the type of each, as it further reinforces what each layer's job is.

LayerFunctionReturn type
mongodb driverfindOneAndUpdate(...)WithId<Document> | null
db/repositoryproductsRepository.updateProduct()Promise<Product | null>
serviceproductsService.updateProduct()Promise<Product | null>
controllerproductsController.patchProduct()res.sendStatus() \n (actual return is void)
routeproductsRouter.patch()200 + JSON · 404 · 400

Note how data that starts as a Document in Mongo makes its way back up the layers and come out at the "top" as an HTTP response.

E2E Testing tests the route as a whole ​

In the second half of the class today we'll look at unit testing, which will help us test each layer's guarantees individually. But before we get into that level of detail let's revisit e2e testing, which seeks to ensure that the route as a whole does what it's supposed to do, without worrying so much about identifying the individual pieces.

We'll start by looking at a couple of already-written e2e tests, then we'll write one together, and then you'll write a few on your own.

How the suite is set up ​

To start, let's review our basic setup for an e2e test with supertest and mongodb memory server.

In __tests__/products.e2e.test.ts:

ts
import {
  afterAll,
  beforeAll,
  beforeEach,
  describe,
  expect,
  it
} from 'vitest';
import request from 'supertest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { app } from '../app.js';
import { connect, disconnect } from '../db/db.js';
import * as productsRepository from '../db/products-repository.js';
import type { Product } from '../models/product.js';
import { testProducts } from './test-products.js';

let mongo: MongoMemoryServer;

beforeAll(async () => {
  mongo = await MongoMemoryServer.create();
  await connect(mongo.getUri(), 'test');
});

afterAll(async () => {
  await disconnect();
  await mongo.stop();
});

The imports at the top are mostly from the testing frameworks we're using, but include a few imports from our app, mostly for setting up the database as needed for tests.

We also need to reset the db between each test, which we do by including a beforeEach() that clears the db and adds the test products back in. This ensures that all tests start in a known state.

ts
beforeEach(async () => {
  await productsRepository._clearProducts();
  for (const product of testProducts) {
    await productsRepository.insertProduct(product);
  }
});

Test #1: The happy path ​

The first test you will often write is the one that makes sure that what you expect to happen when everything goes right actually does. In the case of our PATCH, we expect a well-behaved client to send a PATCH request with a valid ID and some valid fields that are changed to valid values. When that happens, the API should return status 200 with the updated Product in the body.

ts
it('updates the field that was sent', async () => {
  const all = await request(app).get('/products');
  const { id } = all.body[0];

  const res = await request(app)
    .patch(`/products/${id}`)
    .send({ price: 12.5 });

  expect(res.status).toBe(200);
  expect(res.body.price).toBe(12.5);
});

Also note the shape of this test, as it's the pattern you'll generally use for such things:

  • arrange: create the conditions you need for the test to work
    • here, this involves getting a valid ID from the GET route, which we are trusting to work here since it's not what we're testing--other tests should be making sure GET works as advertised.
  • act: do the thing that you are testing
    • here, this is sending the PATCH request and grabbing the response
  • assert: verify that what should be true is true after the action completes
    • here, assert that the status is 200 and the response body contains a price that matches the one that was provided. If these things aren't true after what we just did, the route must be broken.
    • note that we don't actually care which product got updated because $12.50 is a valid price for any product in our system. It is important, however that we know that no existing product starts with a price of 12.5. Look at test-products.ts and convince yourself that if this test succeeds, the route does actually do what it's supposed to do in this case.

Test #2: Test the business logic ​

There are a lot more things that can go wrong than can go right, and most of testing is about figuring out what those things are and making sure that your code does the right thing and doesn't break. Here, we'll test the "business logic" we looked at in updateProduct() in products-service.ts that rejects certain quantity updates. What was that logic by the way? What updates are rejected and what happens when they're rejected? Feel free to take a peek at updateProduct() to refresh your memory.

ts
it('rejects a change that would make quantity negative', async () => {
  const all = await request(app).get('/products');
  const { id } = all.body[0];

  const res = await request(app)
    .patch(`/products/${id}`)
    .send({ quantity: -1 });

  expect(res.status).toBe(400);
  expect(res.body.error).toBe('quantity cannot be negative');
});

Test #3: The 404 ​

One of the most straightforward sad paths is the one where the client tries to update a product that doesn't exist. This one is not provided in the starter code, so we'll write it together.

This one needs no arranging, so we just act (send a patch with aaaaaaaaaaaaaaaaaaaaaaaa as the ID -- you can check test-products.ts if you're worried this might actually be a valid ID) and then assert. Out of curiosity, how far down into the layers does an invalid ID get? And how does the API figure out that a 404 is the right response?

ts
it('404s when the id is not in the database', async () => {
  const res = await request(app)
    .patch(`/products/${'a'.repeat(24)}`)
    .send({ price: 1.5 });

  expect(res.status).toBe(404);
});

Now You Try #1 ​

Lost? Catch up here

Nothing in this exercise depends on anything you wrote — the app is complete and the suite passes. If your npm test isn't green before you start, fix that first.

Your job is to finish the PATCH /products/:id block in src/__tests__/products.e2e.test.ts. Three tests are stubbed with it.todo, which is Vitest's way of saying "planned, not written" — they show up in the output as todo rather than as failures.

Replace each it.todo(...) with a real it(..., async () => { ... }) and make it pass. They are in order of difficulty.

1. rejects a change that would make the price negative

The savvy coder will notice that you have a test already that is very, very similar to this one. Use that as a template and just change what needs changing.

2. leaves the other fields alone

PATCH one field of one product, then check that the field you sent changed and that at least two you didn't send did not. This is also pretty similar to a test you already have, but you'll need to also figure out how to verify that fields that were not part of the request stayed unchanged.

3. leaves the product unchanged after a rejected change

So far we've focused on verifying that bad requests return the correct responses, but we haven't actually checked that they didn't result in bad data making it into the DB. For this test, make a change that gets rejected, then go and ask for the product again and confirm that nothing about it moved.

This test has a different shape from the rest so far: the interesting assertion is about a different request than the one being tested. You PATCH, and then you GET to find out whether the PATCH kept its word.

You're done when... ​

npm test shows no todos and no failures.

Lecture code with Now You Try solutions can be found on GitHub.

Announcements

  • HW2 out today - it mostly covers mongo and REST architecture. I haven't been able to figure out how to have you all write your own tests and assess those in a scalable way. We will expect some degree of test coverage for your final projects, however, so I wouldn't totally check out of this part of the class 😄.
  • HW2 is due in two weeks - by popular request. If you want to visit office hours and the times available don't work, reach out. Both José and I are open to trying to find another time.
  • HW1 solution is delayed - the original plan was to have HW2 start from a solution to HW1 but once we got into the rearchitecting for REST the similarity with HW1 became too hard to maintain. Some of the things you solved in HW1 are indeed solved in the HW2 starter, but they probably show up differently and in different places, so the mapping isn't easy. We are exploring the best way to release a HW1 solution so that you can compare against your solution. If there are differences it doesn't mean one way is right and the other is wrong--we are happy to chat with you about different approaches and why one might choose this or that.
  • Next 3 Weeks:
    • Week of 10/13 - Authentication and Authorization
    • Week of 10/20 - Fall break - HW2 is due, HW3 is released (auth is the only "new" thing in HW3, but there are some twists as well)
    • Week of 10/27 - Midterm In-person Assessments (IPAs) - signup sheet will be available by 10/16, 15 minute slots, we will look at your submitted HW code and ask you questions about it

Unit testing tests one layer alone ​

We've mentioned "unit testing" a few times and now we'll finally get into what that means, why it's needed, and how to do it.

In a nutshell, unit testing is all about testing "units" of functionality--generally speaking the "unit" of interest is a single function. Your entire app is built out of these units of functionality, and they all need to do what they say they will for the whole thing to work.

At a high level, unit tests work the same as e2e tests. You arrange, act, and assert in both. A key difference is that with unit tests you use different tools including mocks and spies to isolate the unit under test so that there is no leakage in or out. You only test the specific unit (i.e., function) and fake everything around it so that any successes or failures can be attributed only to the unit under test.

Regression testing is forever

One of the things that makes unit testing so valuable is that you write the tests once but then you run them ALL THE TIME. Every time you make a change to anything you run the entire set of test suites. This way you guard against a change to one part of the system impacting the way another part works -- this idea of running previously passed tests every time a change is made to any part of the system is called "regression testing," because it's intended to protect against "regression" in the code base (regression means "moving backwards", more or less, and you don't want to be doing that). "Regression testing" does not specify what you're testing or how--and both unit and e2e testing (along with other types) can and usually are part of a regression testing strategy.

Mocks and spies ​

Three essential tools for unit testing are fakes, mocks, and spies.

All of the examples in this section come from src/mocks-and-spies.unit.test.ts, which is in the starter. It doesn't test anything in our app--it's there so you can run these examples yourself with npm run test:unit and poke at them.

A fake is a placeholder function that stands in for a real function. You create a fake in vitest using vi.fn(). They don't do anything, but can behave like real functions when you need them to. Importantly, they can remember when they were called and what they were called with. We'll see why this is useful in a moment, but for now here's an example:

ts
it('a fake function remembers how it was called', () => {
  const notify = vi.fn();

  notify('order shipped', 3);

  expect(notify).toHaveBeenCalledWith('order shipped', 3);
  expect(notify).toHaveBeenCalledTimes(1);
});

A mock is a fake function that returns something you have specified. A mock can replace a real function in code that you're testing so that your code thinks it's calling the function and getting a result, but in fact it's just getting a canned return value that you made up. If you have a fake (via vi.fn()) you can mock its return using mockReturnValue():

ts
it('a fake function can be told what to answer', () => {
  const rollDie = vi.fn().mockReturnValue(4);

  expect(rollDie()).toBe(4);
  expect(rollDie()).toBe(4);
});

If you're mocking an async function, the idea is the same but the function is worded differently since Promises resolve rather than return:

ts
it('an async fake answers with a resolved promise', async () => {
  const loadSettings = vi.fn().mockResolvedValue({ theme: 'dark' });

  expect(await loadSettings()).toEqual({ theme: 'dark' });
});

Creating fake functions and testing them is fun and all, but where it all comes together is when you use vitest to replace real functions with fake ones. You can do this by first creating a spy, which is a wrapper around a real function that keeps track of when it's been called and what it's been called with. To create a spy, you call vi.spyOn() with two arguments--the module or object name, and the function name. In the following code, vi.spyOn() does not replace the real function inventory.countOnHand(), it just keeps track of it.

ts
const inventory = {
  countOnHand: () => 42
};

it('vi.spyOn alone watches, but does not replace', () => {
  const count = vi.spyOn(inventory, 'countOnHand');

  expect(inventory.countOnHand()).toBe(42);
  expect(count).toHaveBeenCalledTimes(1);
});

With fakes, mocks, and spies in your toolbox, you can now put them all together to mock a real function and spy on it all at the same time. This is what you will mainly be doing in your unit tests so that you can make sure that

  • the code you are testing does the right thing when the functions it calls behave as expected, and
  • the code you are testing does not actually impact anything outside itself by calling real functions that have side effects

In the following, vi.spyOn().mockReturnValue() completely replaces inventory.countOnHand(), but the code that calls it (inside the first expect()) is none the wiser.

ts
it('vi.spyOn plus an answer replaces the real function', () => {
  const count = vi.spyOn(inventory, 'countOnHand').mockReturnValue(0);

  expect(inventory.countOnHand()).toBe(0);
  expect(count).toHaveBeenCalledTimes(1);
});

Unit testing in the /products route ​

Here's the function we'll test first. In services/products-service.ts:

ts
export const getLowStockProducts = async (
  threshold: number = LOW_STOCK_THRESHOLD
): Promise<Product[]> => {
  const products = await productsRepository.findAllProducts();
  return products.filter((product) => product.quantity < threshold);
};

Our expectation is that if this function is called with a number (say, 5) and there are, say, two out of three products in the database with quantity < 5, the function will return just those two products and no others. So let's test that to be sure.

Setting up the file ​

This file isn't in the starter--we're writing it from scratch. Create src/services/products-service.unit.test.ts. Copy the next two snippets into that file

ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as productsService from './products-service.js';
import * as productsRepository from '../db/products-repository.js';
import { ValidationError } from '../errors.js';
import type { Product, ProductFields } from '../models/product.js';

beforeEach(() => {
  vi.restoreAllMocks();
});

Note the call to vi.restoreAllMocks() in beforeEach()--this does what it sounds like it does and makes sure that every test starts clean, with all functions back in their normal, unmocked state.

Note also that we're importing code from our app--namely the functions from productsService and productsRepository. We're importing them for different reasons though. productsService contains the functions we are going to test (you can tell this by looking at the test file name--it tells you this test file goes with the code file products-service.ts). productsRepository, however, contains functions that we will mock, since those are where most of the functions called by productsService functions come from.

Next copy in a utility function that will make it easy for our tests to create dummy products to be used in tests.

ts
const makeProduct = (fields: ProductFields): Product => {
  return {
    id: fields.id ?? 'aaaaaaaaaaaaaaaaaaaaaaaa',
    modelName: fields.modelName ?? 'Test Product',
    modelNumber: fields.modelNumber ?? 'TP-1',
    manufacturer: fields.manufacturer ?? 'Test',
    color: fields.color ?? 'Black',
    price: fields.price ?? 10,
    quantity: fields.quantity ?? 10
  };
};

Our first "real" unit test: mocking the repository ​

An e2e test for low stock

As it happens, we already have an e2e test for low stock. It's been in the suite the whole time, in __tests__/products.e2e.test.ts:

ts
it('returns only the low stock products when asked', async () => {
  const res = await request(app).get('/products?lowStock=true');

  expect(res.status).toBe(200);
  expect(res.body).toHaveLength(2);

  const manufacturers = res.body.map(
    (product: Product) => product.manufacturer
  );
  expect(manufacturers).toEqual(['REVOTRA', 'Keurig']);
});

This does indeed test the getLowStockProducts() function, but it also tests a lot more. And if, say, the test failed because the status wasn't 200 or because the returned product array didn't contain 'Keurig' you'd be hard pressed to tell at a glance whether the problem lay with the controller, service, or repository (or somewhere else--maybe test-products.ts?). A unit test gives you a much more focused place to look for what went wrong.

Here's the unit test that gets right at the heart of the matter. In services/products-service.unit.test.ts:

ts
it('returns only the products below the threshold', async () => {
  vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([
    makeProduct({ modelName: 'Plenty', quantity: 100 }),
    makeProduct({ modelName: 'Few', quantity: 2 }),
    makeProduct({ modelName: 'None', quantity: 0 })
  ]);

  const low = await productsService.getLowStockProducts(5);

  const names = low.map((product) => product.modelName);

  expect(names).toEqual(['Few', 'None']);
});

We see the same arrange / act / assert shape we talked about earlier, but each part is doing its thing a bit differently than how it's done in supertest.

  • arrange: vi.spyOn(productsRepository, 'findAllProducts') wraps that one function for the duration of this test, and .mockResolvedValue([...]) mocks it -- so the service gets exactly the three products we want it to see and the real repository is never reached.
  • act: call productsService.getLowStockProducts(5) directly, passing the threshold explicitly rather than leaning on the default, so the test says what it means.
  • assert: two of the three come back, and we check which two.

Crucially, note that when the function under test, getLowStockProducts(), calls productsRepository.findAllProducts() it has no idea it's being duped! Here it is again, back in services/products-service.ts:

ts
export const getLowStockProducts = async (
  threshold: number = LOW_STOCK_THRESHOLD
): Promise<Product[]> => {
  const products = await productsRepository.findAllProducts();
  return products.filter((product) => product.quantity < threshold);
};

It is able to make that call and get the result just as it would if it were running in the production environment.

Pin the boundary ​

Here's a question the e2e test can't answer without new seed data: is a product with exactly five units low stock or not?

Also in services/products-service.unit.test.ts:

ts
it('does not count a product sitting on the threshold', async () => {
  vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([
    makeProduct({ modelName: 'Exactly five', quantity: 5 })
  ]);

  const low = await productsService.getLowStockProducts(5);

  expect(low).toHaveLength(0);
});

Asking the spy what it saw: updateProduct ​

In the last test we mocked a spy but never asked it anything. For this next one, we need to know what the spy knows. In updateProduct(), the service is supposed to pass along only the fields it was given and nothing else. Using a spy we can test this assumption.

Here's the code we are testing (in services/products-service.ts):

ts
export const updateProduct = async (
  id: string,
  changes: ProductFields
): Promise<Product | null> => {
  const existing = await productsRepository.findProduct(id);
  if (!existing) {
    return null;
  }

  const quantity = changes.quantity ?? existing.quantity;
  const price = changes.price ?? existing.price;

  if (quantity < 0) {
    throw new ValidationError('quantity cannot be negative');
  }
  if (price < 0) {
    throw new ValidationError('price cannot be negative');
  }

  return productsRepository.updateProduct(id, changes);
};

... and here's the test, in services/products-service.unit.test.ts:

ts
it('asks the repository to change only what it was given', async () => {
  vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
    makeProduct({ price: 10 })
  );
  const update = vi
    .spyOn(productsRepository, 'updateProduct')
    .mockResolvedValue(makeProduct({ price: 12.5 }));

  await productsService.updateProduct('abc', { price: 12.5 });

  expect(update).toHaveBeenCalledWith('abc', { price: 12.5 });
});

Two repository functions, findProduct() and updateProduct(), are spied on and both are mocked, but for different reasons.

  • findProduct is here only for its mocked answer. The service needs an existing product to compare against, so we hand it one--but we don't ask the spy anything because there's nothing interesting to learn.
  • updateProduct is what this test is interested in. We keep what vi.spyOn returns, in a variable called update, and check what it was called with to make sure it was only the right arguments--the ID and an object containing exactly the one field that was passed to updateProduct() (which, you will recall, is the thing we're actually testing).

A spy with no mock: the test that proves nothing happened ​

There are two validation checks in updateProduct(), and if either of them fail there should be an error thrown and no change to the database. We can use a spy to test this--in this case we want to make sure that NO calls were made to the spied-on function (productsRepository.updateProduct()). We don't give the spy a mock return because we aren't expecting it to be called.

Again in services/products-service.unit.test.ts:

ts
it('writes nothing when quantity would go negative', async () => {
  vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
    makeProduct({ quantity: 10 })
  );
  const update = vi.spyOn(productsRepository, 'updateProduct');

  await expect(
    productsService.updateProduct('abc', { quantity: -1 })
  ).rejects.toThrow(ValidationError);

  expect(update).not.toHaveBeenCalled();
});

Compare the elegance of .not.toHaveBeenCalled() to the hijinks that were needed to complete the third todo in NYT1 earlier (leaves the product unchanged after a rejected change). We had to do a whole 'nother GET to check that no damage was done by a failed update--here we can just verify that the function that would do the damage remains untouched.

Note that we still have the same spy+mock for findProduct() that we had in the previous test, and for the same reason.

Streamlining test running

test:unit vs test:e2e

Look at the scripts in package.json:

json
"test": "vitest run",
"test:unit": "vitest run unit.test --passWithNoTests",
"test:e2e": "vitest run e2e.test"

Run them both and watch the clock:

bash
npm run test:unit
npm run test:e2e

There's nothing clever here: the argument after vitest run is a filter on the test file's path, and our naming convention (*.unit.test.ts next to the code it tests, *.e2e.test.ts in __tests__/) is what makes the filter work.

One final point before turning it over to you: we've now looked at two kinds of tests that you're likely to see a lot of if you work in any kind of software development environment:

  • A unit test exercises one function with its neighbors faked
  • An end-to-end test exercises the whole system through its public interface -- for us, HTTP.

But there are others (many others, actually, depending on who you ask). The one we haven't discussed that you're most likely to encounter, however, is an integration test, which more or less sits in between unit and e2e. These tests look at the integration of more than one component, but mock everything outside the subsystem under test. For example, you might have integration tests that exercise the real service and controller functions, but mock the repositories, or ones that mock the mongo layer to test that the repositories and service layer are behaving as designed. We won't have time to get into integration testing or any of its cousins, but with what we've covered you essentially have the tools for creating and running them--there's just a bit more art to selecting which ones are worth creating and sealing off the boundaries around them than there is with unit or e2e.

Now You Try #2 ​

Here's the whole file so far, plus three it.todo() placeholders for you to fill in. Copy the whole file if you got a little lost, or just copy the todos into the right places.

This should be your src/services/products-service.unit.test.ts to start the NYT:

src/services/products-service.unit.test.ts
ts
// #region setup
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as productsService from './products-service.js';
import * as productsRepository from '../db/products-repository.js';
import { ValidationError } from '../errors.js';
import type { Product, ProductFields } from '../models/product.js';

beforeEach(() => {
  vi.restoreAllMocks();
});
// #endregion setup

// #region factory
const makeProduct = (fields: ProductFields): Product => {
  return {
    id: fields.id ?? 'aaaaaaaaaaaaaaaaaaaaaaaa',
    modelName: fields.modelName ?? 'Test Product',
    modelNumber: fields.modelNumber ?? 'TP-1',
    manufacturer: fields.manufacturer ?? 'Test',
    color: fields.color ?? 'Black',
    price: fields.price ?? 10,
    quantity: fields.quantity ?? 10
  };
};
// #endregion factory

describe('getLowStockProducts', () => {
  // #region lowStock
  it('returns only the products below the threshold', async () => {
    vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([
      makeProduct({ modelName: 'Plenty', quantity: 100 }),
      makeProduct({ modelName: 'Few', quantity: 2 }),
      makeProduct({ modelName: 'None', quantity: 0 })
    ]);

    const low = await productsService.getLowStockProducts(5);

    const names = low.map((product) => product.modelName);

    expect(names).toEqual(['Few', 'None']);
  });
  // #endregion lowStock

  // #region boundary
  it('does not count a product sitting on the threshold', async () => {
    vi.spyOn(productsRepository, 'findAllProducts').mockResolvedValue([
      makeProduct({ modelName: 'Exactly five', quantity: 5 })
    ]);

    const low = await productsService.getLowStockProducts(5);

    expect(low).toHaveLength(0);
  });
  // #endregion boundary
});

describe('updateProduct', () => {
  // #region spy
  it('asks the repository to change only what it was given', async () => {
    vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
      makeProduct({ price: 10 })
    );
    const update = vi
      .spyOn(productsRepository, 'updateProduct')
      .mockResolvedValue(makeProduct({ price: 12.5 }));

    await productsService.updateProduct('abc', { price: 12.5 });

    expect(update).toHaveBeenCalledWith('abc', { price: 12.5 });
  });
  // #endregion spy

  // #region notCalled
  it('writes nothing when quantity would go negative', async () => {
    vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(
      makeProduct({ quantity: 10 })
    );
    const update = vi.spyOn(productsRepository, 'updateProduct');

    await expect(
      productsService.updateProduct('abc', { quantity: -1 })
    ).rejects.toThrow(ValidationError);

    expect(update).not.toHaveBeenCalled();
  });
  // #endregion notCalled

  it.todo('writes nothing when the price would go negative');

  it.todo('returns null when there is no product with that id');
});

describe('createProduct', () => {
  it.todo('fills in defaults before handing it to the repository');
});

Three more todos, in order of difficulty. Same drill as before: replace each it.todo(...) with a real it(..., async () => { ... }) and make it pass.

Run with npm run test:unit.

1. writes nothing when the price would go negative

The service guards two fields, and we only tested one of them. The test directly above this todo provides a template; work out what has to change.

2. returns null when there is no product with that id

A different sad path. When the id isn't in the database, updateProduct doesn't throw an error--go look at the service and see what it does instead. There are two things worth asserting here, and the second one is the same assertion that made the previous test interesting.

Hint: mockResolvedValue(null) is how you fake a lookup that finds nothing.

3. fills in defaults before handing it to the repository

This one is about createProduct, so it sits in its own describe block. Call it with a product that specifies only modelName, and prove that what reached the repository had all six fields filled in.

There is already an e2e test for this, in the POST describe block: fills in defaults for the fields that were left out. That was actually the test that solved the 2nd NYT last week that found the bug I posted about on Canvas. Go read that test, then write the unit test version, and then think about the difference between what you would learn from the two--which one isolates the problem more cleanly?

Note that insertProduct needs an answer as well as being watched, because the service returns whatever comes back from it. makeProduct({}) will do for mocking the resolved value.

You're done when... ​

npm run test:unit shows no todos and no failures, and npm test runs both suites green.

Lecture code with Now You Try solutions can be found on GitHub.

Last updated: