Node.js Testing Best Practices

repository·master·Indexed 26 days ago

https://github.com/goldbergyoni/nodejs-testing-best-practices

A comprehensive guide and reference implementation for modern Node.js testing, focusing on the 'Testing Diamond' strategy. It prioritizes integration and component tests over unit and E2E tests, providing detailed patterns for infrastructure management with Docker-Compose, database optimization for speed, web server lifecycle control, and effective mocking and data management.

Tokens
13.4K
Snippets
37
Records
55
Agent score
87%

What's inside nodejs-testing-best-practices

  1. Overview of Node.js Testing Best Practices

    master

    This project provides a comprehensive guide and a complete showcase application for modern Node.js testing. It focuses on a 'Testing Diamond' strategy, prioritizing integration/component tests over unit and E2E tests to achieve high confidence and developer productivity.

    Key areas covered include:

    • Strategy & Workflow: How to decide what to test and when.
    • Infrastructure: Optimizing databases and message queues for testing.
    • Web Server Setup: Managing the lifecycle of your API.
    • Test Anatomy: The structure of a robust component test.
    • Integrations: Testing 3rd party services and contracts.
    • Data Management: Patterns for handling database state.
    • Message Queues: Testing asynchronous flows.
    • Mocking: When and how to use mocks effectively.
  2. Code against a strict API provider contract

    master

    To prevent mismatches between consumer assumptions and real API behavior, use contract-based testing. Recommended approaches include:

    • OpenAPI/Swagger: Generate an API client from an OpenAPI document (e.g., using openapi-fetch) to enforce correctness at compile time.
    • Shared Schemas: Share TypeScript types or JSON Schemas between provider and consumer.
    • Test-kits: Use a lightweight mock provided by the API provider that includes state-related logic.
    • Consumer-Driven Contracts: Use frameworks like PACT to sync providers and consumers.
  3. Test response schemas for auto-generated fields

    master

    When testing responses containing dynamic or auto-generated data (like IDs, timestamps, or incrementing numbers), assert that the mandatory fields exist and have the correct types using tools like expect.any(Type). Alternatively, validate the entire response against an OpenAPI/Swagger document or a JSON Schema.

    test('When adding a new valid order, Then should get back approval with 200 response', async () => {
      // ...
      //Assert
      expect(receivedAPIResponse).toMatchObject({
        status: 200,
        data: {
          id: expect.any(Number), // Any number satisfies this test
          mode: 'approved',
        },
      });
    });
  4. Handle authentication with real credentials or tokens

    master

    Avoid using security backdoors (like IS_TESTING=TRUE environment variables) or mocking authentication middleware. Instead, test the actual authorization code using one of these methods:

    • JWT/Signed Tokens: If the server expects a signed token, use the same secret in your tests to sign a valid token. This allows the test to act exactly like a real client.
    • External Claim Providers: If the API calls an external service to verify claims, intercept the HTTP call at the network level using tools like nock to return a valid response.
    • Session-based flows: For session-based auth, add the required session key to the session store before running the test.
  5. Test message acknowledgment and failure responses

    master

    When testing MQ flows, do not just check the application state; also assert that the message was correctly acknowledged or rejected by the MQ. For successful flows, verify the ack event. For failure scenarios, verify that the message was not acknowledged so that it can be re-processed, ensuring your error handlers respond correctly to the MQ.

    test('Whenever a user deletion message arrive, then his orders are deleted', async () => {
      const fakeMessageQueue = await startFakeMessageQueue();
      const getNextMQEvent = getNextMQConfirmation(fakeMessageQueue);
    
      // Act
      fakeMessageQueue.pushMessageToQueue('deleted-user', { id: addedOrderId });
    
      // Assert
      const eventFromMessageQueue = await getNextMQEvent;
      expect(eventFromMessageQueue).toEqual([{ event: 'message-acknowledged' }]);
    });
  6. Ensure type safety for mocks

    master

    To prevent tests from passing while production code is broken due to signature mismatches, use type-safe mocking utilities. Popular runners like Jest and Vitest have some non-type-safe functions; ensure you use the versions that support types (e.g., vi.mocked in Vitest) to catch mismatches between the mock and the original implementation at compile time.

    // calculate-price.ts
    export function calculatePrice(): number {
      return 100;
    }
    
    // calculate-price.test.ts
    vi.mocked(calculatePrice).mockImplementation(() => {
      // Vitest example. Works the same with Jest
      return { price: 500 }; // ❌ Type '{ price: number; }' is not assignable to type 'number'
    });
  7. Use partial mocks sparingly

    master

    Partial mocks (where an object is part real and part mocked) are risky because they create 'zombie objects'.

    Best Practices:

    • When mocking objects interacting with external systems, mock the entire object to ensure no hidden real calls slip through. Set all functions to a safe default (e.g., throwing an error or returning undefined) before specifying valid responses for needed functions.
    • The only valid use case for partial mocks is simulating a specific internal failure (e.g., a database connection failure) while allowing the rest of the system to run normally.
    import sinon from 'sinon';
    
    const myObject = {
      methodA: () => 'some value',
      methodB: () => 42,
    };
    
    // Stub all functions to return undefined
    const stubbedObject = sinon.stub(myObject);
    
    console.log(stubbedObject.methodA()); // undefined
  8. Apply unit testing best practices to integration-component tests

    master

    To ensure a great developer experience and prevent test abandonment, write integration-component tests using the same style as unit tests. Follow these constraints:

    • Small scope: Keep tests very small (ideally no longer than 7 statements).
    • Fast execution: Aim for a runtime of a few seconds, staying below 10 seconds.
    • Consistent naming: Use a pattern like when... then....
    • AAA Pattern: Use the Arrange-Act-Assert structure for consistency.
    • Single interaction: Cover a single interaction rather than a large, complex flow.
    // basic-tests.test.ts
    test('When asked for an existing order, Then should retrieve it and receive 200 response', async () => {
      // Arrange
      const orderToAdd = {
        userId: 1,
        productId: 2,
        mode: 'approved',
      };
      const {
        data: { id: addedOrderId },
      } = await axiosAPIClient.post(`/order`, orderToAdd);
    
      // Act
      const getResponse = await axiosAPIClient.get(`/order/${addedOrderId}`);
    
      // Assert
      expect(getResponse).toMatchObject({
        status: 200,
        data: {
          userId: 1,
          productId: 2,
          mode: 'approved',
        },
      });
    });
  9. Test the five potential outcomes

    master

    When planning integration tests, ensure you cover these five categories of outcomes to ensure full confidence in the system:

    1. Response: Verify the correctness of the response data, schema, and HTTP status.
    2. A new state: Verify that the action correctly modified data (e.g., checking the database after an update).
    3. External calls: Verify that the application correctly triggered external components (e.g., sending an SMS or charging a card).
    4. Message queues: Verify that the flow resulted in the expected message being placed in a queue.
    5. Observability: Verify that the system handles errors correctly and produces the expected logs or metrics for SRE/Ops monitoring.
  10. Isolate components using HTTP interceptors

    master

    Isolate the component under test by intercepting outgoing HTTP requests and providing predefined responses. This prevents hitting real external APIs, reducing noise and improving performance. Using a tool like nock allows you to simulate various scenarios (success, failure, chaos) at the network level, keeping tests pure black-box. To mitigate the risk of not detecting changes in the collaborator service, complement this with contract or E2E tests.

    // Intercept requests for 3rd party APIs and return a predefined response
    beforeEach(() => {
      nock('http://localhost/user/').get(`/1`).reply(200, {
        id: 1,
        name: 'John',
      });
    });
  11. Adopt the Testing Diamond strategy

    master

    Instead of a traditional testing pyramid, follow the 'Testing Diamond' approach:

    1. Prioritize Component/Integration Tests: These should be your primary focus. Test entire components (e.g., a microservice) through their public API (HTTP, MQ, etc.) with all internal layers (including the database) included. This provides high confidence and requires minimal mocking.
    2. Minimize E2E Tests: Run only a very small number (e.g., 3-10) of End-to-End tests. Use them only to catch configuration issues, infrastructure problems, or misunderstandings with live third-party collaborators.
    3. Use Unit Tests Selectively: Only use unit tests for non-trivial logic, complex algorithms, or modules where isolation significantly reduces complexity.