Module 4 - Backend Testing

Backend Testing

Backend testing is crucial for ensuring the reliability and functionality of server-side applications. In this module, you'll learn how to test APIs, database interactions, and server endpoints using modern testing tools and best practices.

Learning Objectives

API Testing

Master the art of testing RESTful APIs, including request/response handling, status codes, and data validation.

Database Testing

Learn how to test database operations, including CRUD operations, data integrity, and transaction handling.

Integration Testing

Understand how to write and maintain integration tests that verify the interaction between different components.

Module Content

API Testing

  • HTTP Methods
  • Status Codes
  • Request/Response

Database Testing

  • Test Databases
  • CRUD Operations
  • Data Integrity

Integration Testing

  • Component Interaction
  • Test Environment
  • Mocking & Stubbing

API Testing Fundamentals

Integration Testing with SuperTest

API endpoint testing focuses on ensuring that your REST endpoints properly handle requests and return the expected responses. For this, we can use tools like Supertest along with Jest.

Why Test APIs?

  • Ensure endpoints return correct status codes
  • Verify payload formats and data integrity
  • Confirm error handling works properly
  • Test authorization and authentication flows
  • Maintain functionality when refactoring

Code Example - Setting up Supertest:

const request = require('supertest');
const server = require('../api/server');

describe('server.js', () => {
  describe('GET /', () => {
    it('returns 200 OK', async () => {
      const response = await request(server).get('/');
      expect(response.status).toBe(200);
    });

    it('returns JSON', async () => {
      const response = await request(server).get('/');
      expect(response.type).toBe('application/json');
    });

    it('returns the expected message', async () => {
      const response = await request(server).get('/');
      expect(response.body.message).toBe('API is running');
    });
  });
});

Testing Authentication Endpoints

Authentication is a critical part of most applications, and it's essential to test these endpoints thoroughly.

Code Example - Testing Registration and Login:

describe('Authentication Endpoints', () => {
  beforeAll(async () => {
    // Clear test database before all tests
    await db('users').truncate();
  });

  describe('POST /api/auth/register', () => {
    it('returns 201 Created when registration succeeds', async () => {
      const newUser = { username: 'testuser', password: 'password123' };
      const response = await request(server)
        .post('/api/auth/register')
        .send(newUser);
        
      expect(response.status).toBe(201);
    });
    
    it('saves the user to the database', async () => {
      const users = await db('users');
      expect(users).toHaveLength(1);
    });
    
    it('returns 400 Bad Request if username is missing', async () => {
      const invalidUser = { password: 'password123' };
      const response = await request(server)
        .post('/api/auth/register')
        .send(invalidUser);
        
      expect(response.status).toBe(400);
    });
  });
  
  describe('POST /api/auth/login', () => {
    it('returns 200 OK and token when login succeeds', async () => {
      const credentials = { username: 'testuser', password: 'password123' };
      const response = await request(server)
        .post('/api/auth/login')
        .send(credentials);
        
      expect(response.status).toBe(200);
      expect(response.body.token).toBeDefined();
    });
    
    it('returns 401 Unauthorized with invalid credentials', async () => {
      const invalidCreds = { username: 'testuser', password: 'wrongpassword' };
      const response = await request(server)
        .post('/api/auth/login')
        .send(invalidCreds);
        
      expect(response.status).toBe(401);
    });
  });
});

Testing Protected Routes

Protected routes require a valid token for access. Testing these routes involves first obtaining a token and then using it in subsequent requests.

Code Example - Testing Protected Endpoints:

describe('Protected Routes', () => {
  let token;
  
  beforeAll(async () => {
    // Clear test database
    await db('users').truncate();
    
    // Register a test user
    await request(server)
      .post('/api/auth/register')
      .send({ username: 'protecteduser', password: 'password123' });
    
    // Login to get a token
    const response = await request(server)
      .post('/api/auth/login')
      .send({ username: 'protecteduser', password: 'password123' });
    
    token = response.body.token;
  });
  
  describe('GET /api/resources', () => {
    it('returns 401 without token', async () => {
      const response = await request(server).get('/api/resources');
      expect(response.status).toBe(401);
    });
    
    it('returns 200 with valid token', async () => {
      const response = await request(server)
        .get('/api/resources')
        .set('Authorization', `Bearer ${token}`);
        
      expect(response.status).toBe(200);
      expect(Array.isArray(response.body)).toBe(true);
    });
  });
});

Database Testing

Database Integration Testing

Testing database operations is crucial for ensuring your data layer works correctly. This typically involves testing your models and data access functions.

Setting Up Test Database Environment

It's important to use a separate database for testing to avoid affecting production data. A common practice is to use SQLite in-memory database for testing:

Code Example - Database Testing Setup:

// knexfile.js
module.exports = {
  development: {
    client: 'sqlite3',
    connection: {
      filename: './data/dev.sqlite3'
    },
    // ... other config
  },
  testing: {
    client: 'sqlite3',
    connection: {
      filename: ':memory:'  // In-memory SQLite database
    },
    // ... other config
    seeds: {
      directory: './data/seeds'
    }
  }
};

Database Helper Functions for Testing:

// dbTestHelpers.js
const db = require('../data/dbConfig');

async function truncateAllTables() {
  // Truncate all tables to start with fresh data
  await db('users').truncate();
  await db('posts').truncate();
  await db('comments').truncate();
}

async function seedTestData() {
  // Insert test data
  await db('users').insert([
    { id: 1, username: 'testuser1', password: 'hashed_password' },
    { id: 2, username: 'testuser2', password: 'hashed_password' }
  ]);
  // ... other seed data
}

module.exports = {
  truncateAllTables,
  seedTestData
};

Testing CRUD Operations

For a robust application, each Create, Read, Update, and Delete operation should be thoroughly tested.

Code Example - Testing User Model:

const db = require('../data/dbConfig');
const Users = require('../models/users-model');
const { truncateAllTables } = require('./dbTestHelpers');

describe('Users Model', () => {
  beforeEach(async () => {
    await truncateAllTables();
  });
  
  describe('getAll()', () => {
    it('returns an empty array if no users exist', async () => {
      const users = await Users.getAll();
      expect(users).toEqual([]);
    });
    
    it('returns all users in the database', async () => {
      // Add test users
      await db('users').insert([
        { username: 'user1', password: 'pass1' },
        { username: 'user2', password: 'pass2' }
      ]);
      
      const users = await Users.getAll();
      expect(users).toHaveLength(2);
      expect(users[0].username).toBe('user1');
      expect(users[1].username).toBe('user2');
    });
  });
  
  describe('findById()', () => {
    it('returns the user with the specified id', async () => {
      // Add a test user
      const [id] = await db('users').insert({ 
        username: 'finduser', 
        password: 'pass' 
      });
      
      const user = await Users.findById(id);
      expect(user.username).toBe('finduser');
    });
    
    it('returns undefined for non-existent id', async () => {
      const user = await Users.findById(9999);
      expect(user).toBeUndefined();
    });
  });
  
  describe('add()', () => {
    it('adds the user to the database', async () => {
      const newUser = { username: 'newuser', password: 'newpass' };
      await Users.add(newUser);
      
      const users = await db('users');
      expect(users).toHaveLength(1);
      expect(users[0].username).toBe('newuser');
    });
    
    it('returns the added user with an id', async () => {
      const newUser = { username: 'adduser', password: 'addpass' };
      const added = await Users.add(newUser);
      
      expect(added.id).toBeDefined();
      expect(added.username).toBe('adduser');
    });
  });
  
  // ... tests for update() and remove()
});

Testing Database Transactions

Some database operations involve multiple steps or transactions that should be tested together.

Code Example - Testing a Complex Operation:

describe('Posts Model - Complex Operations', () => {
  beforeEach(async () => {
    await truncateAllTables();
    await seedTestData();
  });
  
  describe('createPostWithTags()', () => {
    it('creates a post with multiple tags', async () => {
      const newPost = {
        title: 'Test Post',
        content: 'This is a test post',
        user_id: 1,
        tags: ['javascript', 'testing', 'jest']
      };
      
      const result = await Posts.createPostWithTags(newPost);
      
      // Check post was created
      expect(result.post.id).toBeDefined();
      expect(result.post.title).toBe('Test Post');
      
      // Check tags were created
      expect(result.tags).toHaveLength(3);
      
      // Check post-tag relationships
      const postTags = await db('post_tags')
        .where({ post_id: result.post.id });
      expect(postTags).toHaveLength(3);
    });
    
    it('rolls back if tag creation fails', async () => {
      // Mock db.transaction to simulate failure
      const originalTransaction = db.transaction;
      db.transaction = jest.fn(async (callback) => {
        const trx = {
          commit: jest.fn(),
          rollback: jest.fn(),
          insert: jest.fn(() => {
            throw new Error('Test error');
          }),
          // ... other trx methods as needed
        };
        try {
          await callback(trx);
        } catch (err) {
          await trx.rollback();
          throw err;
        }
      });
      
      const newPost = {
        title: 'Rollback Test Post',
        content: 'This should be rolled back',
        user_id: 1,
        tags: ['javascript', 'testing']
      };
      
      await expect(Posts.createPostWithTags(newPost))
        .rejects.toThrow('Test error');
      
      // Check nothing was added
      const posts = await db('posts');
      expect(posts).toHaveLength(0);
      
      // Restore original transaction function
      db.transaction = originalTransaction;
    });
  });
});

Integration Testing Best Practices

Integration Testing Strategy

Test-Driven Development (TDD) is an effective approach for developing robust backend APIs. The process involves:

  1. Write a failing test for the API endpoint or database function
  2. Implement the minimal code to make the test pass
  3. Refactor the code while keeping tests passing

Benefits of TDD for Backend:

  • Forces you to think about how your API should behave
  • Ensures testable code from the start
  • Provides immediate feedback as you develop
  • Leads to modular and maintainable code
  • Serves as documentation for how the API works

Code Example - TDD Workflow:

// Step 1: Write a failing test
describe('POST /api/resources', () => {
  it('creates a new resource with valid data', async () => {
    const newResource = { name: 'Test Resource', value: 100 };
    const response = await request(server)
      .post('/api/resources')
      .send(newResource);
      
    expect(response.status).toBe(201);
    expect(response.body.id).toBeDefined();
    expect(response.body.name).toBe('Test Resource');
    
    // Verify it was saved to the database
    const resources = await db('resources');
    expect(resources).toHaveLength(1);
  });
});

Measuring Test Coverage

Jest provides built-in code coverage reporting. You can enable it by adding the --coverage flag to your test command:

// package.json
{
  "scripts": {
    "test": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Test Organization Tips:

  • Group tests by feature or component
  • Use descriptive test names that explain what's being tested
  • Follow the AAA pattern (Arrange, Act, Assert)
  • Keep tests independent and isolated
  • Use test fixtures for common test setups
// Well-organized test structure
describe('Users API', () => {
  describe('GET /api/users', () => {
    // Tests for listing users
  });
  
  describe('GET /api/users/:id', () => {
    // Tests for retrieving a specific user
  });
  
  describe('POST /api/users', () => {
    // Tests for creating users
  });
  
  describe('PUT /api/users/:id', () => {
    // Tests for updating users
  });
  
  describe('DELETE /api/users/:id', () => {
    // Tests for deleting users
  });
});

Setting Up Testing Environments

Setting up the right testing environment is essential for reliable and efficient tests.

Key Components of a Testing Environment:

  • Dedicated test database that resets between test runs
  • Environment-specific configuration (using NODE_ENV=test)
  • Mock services for external dependencies
  • Continuous integration setup to run tests automatically

Code Example - Jest Setup File:

// jest.setup.js
const db = require('./data/dbConfig');

// Global setup before all tests
beforeAll(async () => {
  // Run migrations to set up the test database schema
  await db.migrate.latest();
});

// Global cleanup after all tests
afterAll(async () => {
  // Close database connection
  await db.destroy();
});

// Reset database before each test suite
beforeEach(async () => {
  // Run seeds to reset to a known state
  await db.seed.run();
});

Jest Configuration:

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  setupFilesAfterEnv: ['./jest.setup.js'],
  collectCoverageFrom: [
    '**/*.{js,jsx}',
    '!**/node_modules/**',
    '!**/vendor/**',
    '!**/*.config.js',
  ],
  testPathIgnorePatterns: ['/node_modules/'],
  verbose: true
};

Guided Project

Backend Testing Implementation

Build a comprehensive test suite for a Node.js backend application

View Project

Resources

Tools & Libraries

  • SuperTest - HTTP Testing Library
  • Postman - API Testing Tool
  • TestContainers - Database Testing