CSE 341: Web Services

W06 Learning Activity: Advanced Testing Concepts

Overview

Exposure, not implementation

You are not required to use dependency injection, mocks, or stubs in this week's assignment. Your team will write tests against the real Express application and a temporary database. This activity introduces terms and patterns that you may see in documentation, job interviews, and professional codebases.

Different Tests Answer Different Questions

The tests in the previous activity sent requests through the real Express application and used a temporary MongoDB database. These tests answer an important question: “Do the parts of this endpoint work together?”

Sometimes a developer needs to ask a smaller question. For example:

Testing one small part can make a failure easier to locate. However, a small test cannot prove that the complete system works together. Professional test suites often contain a mixture of test types.

Common Levels of Testing

Test Level Main Question Example
Unit test Does one small function or module behave correctly? Test a fare calculation with plain input values.
Integration test Do several parts work together? Send a request through Express and save a record in a temporary database.
End-to-end test Does the complete system work as a user experiences it? Open a browser, sign in, submit a form, and verify the result.

Unit tests are normally small and fast. Integration tests cover more of the application but require more setup. End-to-end tests cover the largest path but are often slower and more difficult to maintain. None of these levels replaces all the others.

Dependencies Make Focused Tests More Difficult

A dependency is something a unit of code needs in order to do its work. A controller may depend on an order service. An order service may depend on a Mongoose model. A notification service may depend on an email provider.

If a focused test uses every real dependency, the test may become slow, may give different results at different times, or may affect real services or data. Sending a real email during a test would be a bad idea. Calling a payment provider could cost money. Depending on a remote server could make the test fail when the network is unavailable.

Developers sometimes replace a real dependency with a test double. The name comes from a stunt double who temporarily takes an actor's place. The replacement is designed only for the test.

Types of Test Doubles

The words used for test doubles sometimes overlap. Different teams and testing libraries may use them in slightly different ways. These definitions describe the most common ideas.

Stubs

A stub returns a result chosen by the test. Suppose a controller calls a service to find a product. A test can provide a service that always returns one known product.

const productServiceStub = {
  findById: async () => ({
    _id: 'product-100',
    name: 'Wireless Keyboard',
    price: 49.99
  })
};

The test can also create a not-found situation without searching a database:

const productServiceStub = {
  findById: async () => null
};

Or it can create an error situation:

const productServiceStub = {
  findById: async () => {
    throw new Error('The product service is unavailable.');
  }
};

Each stub makes the dependency predictable. The test can focus on how the controller responds to that result.

Dependency Injection

Before a test can use a stub, the application needs a way to receive it. Dependency injection means that code receives a dependency from the outside instead of always importing or creating it internally.

Without dependency injection, a controller may import a service directly:

import * as productService from '../services/product-service.js';

export async function getProduct(req, res, next) {
  const product = await productService.findById(req.params.id);
  // Continue handling the result.
}

With dependency injection, a function receives the service. The example below uses one style of dependency injection, where a factory function receives the dependency once and returns a handler that remembers it:

export function createGetProductController({ productService }) {
  return async function getProduct(req, res, next) {
    try {
      const product = await productService.findById(req.params.id);

      if (!product) {
        return res.status(404).json({
          message: 'Product not found.'
        });
      }

      return res.status(200).json(product);
    } catch (error) {
      return next(error);
    }
  };
}

The production application can provide the real service. A focused test can provide a stub:

const getProduct = createGetProductController({
  productService: productServiceStub
});

Dependency injection can make code easier to test and replace. It also adds structure that a team must understand and use consistently. It is helpful in many applications, but it is not required for every small project.

Spies and Interaction Checks

Sometimes the important behavior is that one part of the application asks another part to do something. For example, an order service may request a confirmation email. A test should not send a real email, but it may need to verify that the email service was called correctly. The example below uses a second style of dependency injection, where the dependency is passed as an argument on each call instead of being received once through a factory function.

import { expect, test, vi } from 'vitest';

test('requests a confirmation email', async () => {
  const emailService = {
    sendOrderConfirmation: vi.fn()
  };

  await confirmOrder(
    {
      orderNumber: 'ORD-100',
      customerEmail: 'customer@example.com'
    },
    { emailService }
  );

  expect(emailService.sendOrderConfirmation)
    .toHaveBeenCalledWith(
      'customer@example.com',
      expect.objectContaining({
        orderNumber: 'ORD-100'
      })
    );
});

vi.fn() creates a function that records its calls. The toHaveBeenCalledWith() matcher checks the arguments from the call.

This type of test checks an interaction rather than a returned value. Interaction tests are useful for operations such as sending email, recording an audit event, or publishing a message.

Module Mocking

Some code was not designed to receive dependencies. Testing libraries can replace an imported module during a test. Vitest provides vi.mock() for this purpose.

import { beforeEach, expect, test, vi } from 'vitest';
import { sendOrderEmail } from '../src/services/email-service.js';
import { completeOrder } from '../src/services/order-service.js';

vi.mock('../src/services/email-service.js', () => ({
  sendOrderEmail: vi.fn()
}));

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

test('sends an email after completing an order', async () => {
  await completeOrder({
    email: 'customer@example.com'
  });

  expect(sendOrderEmail).toHaveBeenCalledOnce();
});

Module mocking can be useful, but it has rules about import timing and cleanup. It can also make a test more difficult to understand because the imported module does not behave normally. For those reasons, this course does not require you to use vi.mock().

Fakes

A fake is a smaller implementation that still performs useful work. For example, a fake repository might store records in an array instead of MongoDB.

class FakeProductRepository {
  constructor() {
    this.products = [];
  }

  async create(productData) {
    const product = {
      _id: String(this.products.length + 1),
      ...productData
    };

    this.products.push(product);
    return product;
  }

  async findAll() {
    return this.products;
  }
}

A fake can support several tests and behave more like a real dependency than a small stub. However, its behavior may still differ from the real system. An array does not enforce every MongoDB rule. Tests against a fake database should not completely replace tests against real database behavior.

MongoDB Memory Server is sometimes described as a fake in a broad sense because it replaces the deployed database during testing. However, it runs a real MongoDB server process. That makes it a strong choice when the purpose is to verify Mongoose and MongoDB behavior together.

Stubbing Time, Random Values, and Environment Data

Not all dependencies are separate services. Code may depend on the current time, a random number, or an environment variable. These values can make tests unpredictable.

Vitest can control the clock during a test:

import { afterEach, expect, test, vi } from 'vitest';

afterEach(() => {
  vi.useRealTimers();
});

test('records the order date', () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2030-05-10T12:00:00Z'));

  const order = createOrderRecord();

  expect(order.createdAt.toISOString())
    .toBe('2030-05-10T12:00:00.000Z');
});

Controlling time lets the test compare a known value instead of depending on the second when the test happened to run.

Fixtures and Factories

Tests may need the same kinds of data many times. A fixture is known data prepared for tests. The records created by your database initialization function are fixtures.

A test data factory is a function that creates valid test data and allows a test to change only the fields it cares about.

function makeOrder(overrides = {}) {
  return {
    items: [{
      productId: 'product-100',
      quantity: 1
    }],
    shippingAddress: '123 Main Street',
    customerEmail: 'customer@example.com',
    ...overrides
  };
}

const largeOrder = makeOrder({
  items: [{
    productId: 'product-100',
    quantity: 6
  }]
});

Factories can make a large test suite easier to maintain. However, using factories too often can hide important values. A test should still make the details related to its behavior easy to see.

Contract Testing

An API contract describes what a client can expect: methods, URLs, status codes, headers, and response bodies. The Supertest tests from the previous activity are a basic form of contract checking because they verify observable API behavior.

Larger systems may use special contract-testing tools. For example, a frontend team can record what it expects from an API, and the backend team can verify that the API still satisfies those expectations. This is especially helpful when different teams deploy services separately.

The Cost of Too Many Test Doubles

Test doubles are useful, but they introduce a risk. A stub behaves the way the test author tells it to behave. The real dependency may behave differently.

Imagine that a test stubs a database service to return this object:

{ id: '100', productName: 'Wireless Keyboard' }

If the real service actually returns _id and name, the focused test may pass while the real application fails. Integration tests help detect this type of mismatch.

Tests can also become too closely connected to implementation details. If a test expects exactly which internal functions are called, harmless code improvements may break the test even when the API behavior is still correct.

Prefer behavior over implementation details

When possible, test what a client can observe: the returned value, status code, response body, or saved result. Check internal function calls only when that interaction is the behavior that matters.

Choosing an Approach

Testing Goal Possible Approach
Verify a pure calculation Call the function directly with plain input.
Verify an Express endpoint and MongoDB behavior together Use Supertest and MongoDB Memory Server.
Make a service return one controlled result Provide a stub through dependency injection.
Verify that an email operation was requested Use a spy or mock instead of sending a real email.
Replace a complicated dependency with a smaller working version Use a fake.
Verify a complete user workflow Use an end-to-end test through the user interface.

There is no single testing approach that is best for every question. The approach should match the risk and the behavior you need to verify.

Activity Instructions

Check your understanding

  1. What is the difference between a unit test and an integration test?
    Click to expand the example answer

    A unit test focuses on one small function or module. An integration test checks whether several real parts of the application work together.

  2. What is the purpose of a stub?
    Click to expand the example answer

    A stub returns a controlled result from a dependency. This lets the test create a specific success, not-found, or error situation.

  3. How does dependency injection make stubbing easier?
    Click to expand the example answer

    The code receives its dependency from the outside. Production code can provide the real dependency, while a test can provide a controlled replacement.

  4. When might an interaction check be more useful than checking a return value?
    Click to expand the example answer

    An interaction check is useful when requesting an action is the important behavior, such as asking an email service to send a confirmation without sending a real email during the test.

  5. Why should focused tests with stubs not replace every integration test?
    Click to expand the example answer

    A stub may not behave exactly like the real dependency. Integration tests verify that the real parts agree about data shapes and behavior.

Submission

No advanced testing implementation is required from this activity. Use Vitest, Supertest, and the temporary MongoDB database for your Week 06 team project tests.

Other Links: