Building a RESTful API from Scratch with Node.js (Part 6)
APIs tutorialcreate an APIAPI development guideTutorial

6 min read

dd## Setting Up Automated Testing for Your Node.js API

This tutorial builds on our documented API by implementing comprehensive automated testing. We'll cover unit tests, integration tests, and end-to-end testing using popular testing frameworks and best practices.

Prerequisites

  • Completed Parts 1-5 of the tutorial
  • Basic understanding of testing concepts
  • Node.js and npm installed

Project Setup

Install the required dependencies:

npm install --save-dev jest supertest mongodb-memory-server faker @types/jest
npm install --save-dev @babel/core @babel/preset-env

Updated Project Structure

Add these new files to your project:

books-api/
├── __tests__/
│   ├── unit/
│   │   ├── models/
│   │   │   ├── book.test.js
│   │   │   └── user.test.js
│   │   └── utils/
│   │       └── validation.test.js
│   ├── integration/
│   │   ├── auth.test.js
│   │   └── books.test.js
│   ├── e2e/
│   │   └── api.test.js
│   └── fixtures/
│       ├── books.js
│       └── users.js
├── jest.config.js
├── babel.config.js
└── src/
    ├── test/
    │   ├── setup.js
    │   └── teardown.js
    └── utils/
        └── testUtils.js

Jest Configuration

Create jest.config.js:

module.exports = {
    testEnvironment: 'node',
    testMatch: [
        '**/__tests__/**/*.test.js',
        '**/?(*.)+(spec|test).js'
    ],
    coveragePathIgnorePatterns: [
        '/node_modules/',
        '/__tests__/fixtures/'
    ],
    setupFilesAfterEnv: ['<rootDir>/src/test/setup.js'],
    globalTeardown: '<rootDir>/src/test/teardown.js',
    collectCoverage: true,
    coverageReporters: ['text', 'lcov', 'clover'],
    coverageThreshold: {
        global: {
            branches: 80,
            functions: 80,
            lines: 80,
            statements: 80
        }
    }
};

Babel Configuration

Create babel.config.js:

module.exports = {
    presets: [
        [
            '@babel/preset-env',
            {
                targets: {
                    node: 'current'
                }
            }
        ]
    ]
};

Test Setup and Teardown

Create src/test/setup.js:

const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');

let mongod;

beforeAll(async () => {
    // Start in-memory MongoDB instance
    mongod = await MongoMemoryServer.create();
    const uri = mongod.getUri();
    await mongoose.connect(uri);

    // Set up test environment variables
    process.env.JWT_SECRET = 'test-secret';
    process.env.JWT_EXPIRE = '1h';
});

afterAll(async () => {
    // Cleanup
    await mongoose.disconnect();
    await mongod.stop();
});

beforeEach(async () => {
    // Clear all collections before each test
    const collections = await mongoose.connection.db.collections();
    for (let collection of collections) {
        await collection.deleteMany({});
    }
});

// Global test helpers
global.generateAuthToken = (userId, role = 'user') => {
    return jwt.sign(
        { id: userId, role },
        process.env.JWT_SECRET,
        { expiresIn: process.env.JWT_EXPIRE }
    );
};

Create src/test/teardown.js:

module.exports = async () => {
    // Add any cleanup code needed after all tests complete
};

Test Utilities

Create src/utils/testUtils.js:

const faker = require('faker');
const { Book } = require('../models/book');
const { User } = require('../models/user');

const generateFakeBook = (overrides = {}) => ({
    title: faker.commerce.productName(),
    author: faker.name.findName(),
    isbn: faker.random.alphaNumeric(13),
    publishedDate: faker.date.past(),
    genre: faker.random.arrayElement(['fiction', 'non-fiction', 'science', 'history']),
    description: faker.lorem.paragraph(),
    ...overrides
});

const generateFakeUser = (overrides = {}) => ({
    name: faker.name.findName(),
    email: faker.internet.email(),
    password: faker.internet.password(),
    role: faker.random.arrayElement(['user', 'editor', 'admin']),
    ...overrides
});

const createTestBook = async (overrides = {}) => {
    const bookData = generateFakeBook(overrides);
    return await Book.create(bookData);
};

const createTestUser = async (overrides = {}) => {
    const userData = generateFakeUser(overrides);
    return await User.create(userData);
};

module.exports = {
    generateFakeBook,
    generateFakeUser,
    createTestBook,
    createTestUser
};

Unit Tests

Create __tests__/unit/models/book.test.js:

const mongoose = require('mongoose');
const { Book } = require('../../../src/models/book');
const { generateFakeBook } = require('../../../src/utils/testUtils');

describe('Book Model Tests', () => {
    it('should create a valid book', async () => {
        const bookData = generateFakeBook();
        const validBook = new Book(bookData);
        const savedBook = await validBook.save();
        
        expect(savedBook._id).toBeDefined();
        expect(savedBook.title).toBe(bookData.title);
        expect(savedBook.author).toBe(bookData.author);
    });

    it('should fail validation without required fields', async () => {
        const invalidBook = new Book({});
        let error;

        try {
            await invalidBook.save();
        } catch (err) {
            error = err;
        }

        expect(error).toBeDefined();
        expect(error.errors.title).toBeDefined();
        expect(error.errors.author).toBeDefined();
    });

    it('should sanitize HTML in description', async () => {
        const bookWithHtml = generateFakeBook({
            description: '<script>alert("xss")</script><p>Safe content</p>'
        });
        
        const book = new Book(bookWithHtml);
        const savedBook = await book.save();
        
        expect(savedBook.description).toBe('Safe content');
    });
});

Integration Tests

Create __tests__/integration/books.test.js:

const request = require('supertest');
const app = require('../../src/server');
const { createTestUser, createTestBook } = require('../../src/utils/testUtils');

describe('Books API Integration Tests', () => {
    let authToken;
    let testUser;

    beforeEach(async () => {
        testUser = await createTestUser({ role: 'admin' });
        authToken = generateAuthToken(testUser._id, testUser.role);
    });

    describe('GET /api/books', () => {
        it('should return all books', async () => {
            // Create test books
            const books = await Promise.all([
                createTestBook(),
                createTestBook(),
                createTestBook()
            ]);

            const response = await request(app)
                .get('/api/books')
                .expect(200);

            expect(response.body).toHaveLength(3);
            expect(response.body[0]).toHaveProperty('title');
            expect(response.body[0]).toHaveProperty('author');
        });

        it('should filter books by genre', async () => {
            await Promise.all([
                createTestBook({ genre: 'fiction' }),
                createTestBook({ genre: 'non-fiction' }),
                createTestBook({ genre: 'fiction' })
            ]);

            const response = await request(app)
                .get('/api/books')
                .query({ genre: 'fiction' })
                .expect(200);

            expect(response.body).toHaveLength(2);
            expect(response.body.every(book => book.genre === 'fiction')).toBe(true);
        });
    });

    describe('POST /api/books', () => {
        it('should create a new book with valid auth', async () => {
            const newBook = generateFakeBook();

            const response = await request(app)
                .post('/api/books')
                .set('Authorization', `Bearer ${authToken}`)
                .send(newBook)
                .expect(201);

            expect(response.body).toHaveProperty('_id');
            expect(response.body.title).toBe(newBook.title);
        });

        it('should reject unauthorized creation attempts', async () => {
            const newBook = generateFakeBook();

            await request(app)
                .post('/api/books')
                .send(newBook)
                .expect(401);
        });
    });
});

End-to-End Tests

Create __tests__/e2e/api.test.js:

const request = require('supertest');
const app = require('../../src/server');
const { createTestUser, generateFakeBook } = require('../../src/utils/testUtils');

describe('API End-to-End Tests', () => {
    let authToken;
    let testUser;

    beforeAll(async () => {
        // Set up test user and authentication
        testUser = await createTestUser({ role: 'admin' });
        const loginResponse = await request(app)
            .post('/api/auth/login')
            .send({
                email: testUser.email,
                password: testUser.password
            });
        authToken = loginResponse.body.token;
    });

    describe('Complete Book Management Flow', () => {
        it('should perform full CRUD operation sequence', async () => {
            // Create a new book
            const newBook = generateFakeBook();
            const createResponse = await request(app)
                .post('/api/books')
                .set('Authorization', `Bearer ${authToken}`)
                .send(newBook)
                .expect(201);

            const bookId = createResponse.body._id;

            // Read the created book
            const getResponse = await request(app)
                .get(`/api/books/${bookId}`)
                .expect(200);

            expect(getResponse.body.title).toBe(newBook.title);

            // Update the book
            const updateData = { title: 'Updated Title' };
            await request(app)
                .put(`/api/books/${bookId}`)
                .set('Authorization', `Bearer ${authToken}`)
                .send(updateData)
                .expect(200);

            // Verify update
            const updatedResponse = await request(app)
                .get(`/api/books/${bookId}`)
                .expect(200);

            expect(updatedResponse.body.title).toBe(updateData.title);

            // Delete the book
            await request(app)
                .delete(`/api/books/${bookId}`)
                .set('Authorization', `Bearer ${authToken}`)
                .expect(200);

            // Verify deletion
            await request(app)
                .get(`/api/books/${bookId}`)
                .expect(404);
        });
    });
});

Test Reports and Coverage

Add scripts to package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:unit": "jest --testMatch='**/__tests__/unit/**/*.test.js'",
    "test:integration": "jest --testMatch='**/__tests__/integration/**/*.test.js'",
    "test:e2e": "jest --testMatch='**/__tests__/e2e/**/*.test.js'",
    "test:ci": "jest --ci --coverage --reporters='default' --reporters='jest-junit'"
  }
}

GitHub Actions Integration

Create .github/workflows/test.yml:

name: Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [14.x, 16.x]

    steps:
    - uses: actions/checkout@v2
    
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v2
      with:
        node-version: ${{ matrix.node-version }}
        
    - name: Install dependencies
      run: npm ci
      
    - name: Run tests
      run: npm run test:ci
      
    - name: Upload coverage
      uses: codecov/codecov-action@v2
      with:
        file: ./coverage/lcov.info

Performance Testing

Create __tests__/performance/load.test.js:

const autocannon = require('autocannon');

const performLoadTest = async () => {
    const result = await autocannon({
        url: 'http://localhost:3000/api/books',
        connections: 10,
        duration: 10,
        pipelining: 1,
        headers: {
            'Accept': 'application/json'
        }
    });

    console.log(result);
};

performLoadTest();

Best Practices Implemented

  1. Test Organization

    • Clear separation of test types
    • Reusable test utilities
    • Consistent naming conventions
    • Modular test structure
  2. Test Coverage

    • Unit tests for models and utilities
    • Integration tests for API endpoints
    • End-to-end tests for complete flows
    • Performance testing
  3. Test Environment

    • In-memory MongoDB for tests
    • Environment variable management
    • Clean state between tests
    • Proper async/await handling
  4. CI/CD Integration

    • GitHub Actions configuration
    • Coverage reporting
    • Multiple Node.js versions
    • Automated test runs

Next Steps

To enhance your testing setup:

  1. Add API contract testing
  2. Implement mutation testing
  3. Add visual regression testing
  4. Set up load testing
  5. Add security testing
  6. Implement browser testing
  7. Add accessibility testing

Conclusion

You now have a comprehensive testing suite that:

  • Ensures code quality
  • Catches bugs early
  • Maintains API reliability
  • Supports continuous integration
  • Provides confidence in changes

Remember to:

  • Keep tests up to date
  • Monitor test coverage
  • Review test performance
  • Update test data regularly
  • Document test procedures

The next tutorial will cover adding pagination for GET requests.

Related Posts

API Management Tools: A Comprehensive Overview
APIs tutorialcreate an APIAPI development guideTutorial

5 min read

APIs (Application Programming Interfaces) are the backbone of modern digital applications. They allow different software systems to communicate, exchange data, and collaborate seamlessly. As businesse...

API Security: Best Practices for Protecting Your Application
APIs tutorialcreate an APIAPI development guideTutorial

4 min read

In today’s interconnected digital world, APIs (Application Programming Interfaces) are the backbone of communication between different software applications. From mobile apps to cloud services, APIs e...

API Design Best Practices: Crafting Robust and Scalable APIs
APIs tutorialcreate an APIAPI development guideTutorial

5 min read

In the modern digital ecosystem, APIs (Application Programming Interfaces) serve as the backbone of connectivity. Whether you're building microservices, enabling integrations, or crafting data pipelin...