Module 4: Backend Testing

Learning Objectives

By the end of this module, you should be able to:

  • Implement comprehensive testing for backend applications
  • Create tests for API endpoints using supertest
  • Test database interactions with proper isolation
  • Implement integration tests for complete workflows
  • Structure your tests for maintainability and effectiveness

Video Lesson

Introduction to Backend Testing

Backend testing is crucial for ensuring your API endpoints, database interactions, and business logic work as expected. It builds upon unit testing concepts, but focuses on testing the server-side components of your application.

Types of Backend Tests

  • API Testing: Testing API endpoints to ensure they return the correct responses
  • Database Testing: Testing interactions with your database
  • Integration Testing: Testing how multiple components work together
  • End-to-End Testing: Testing complete workflows from start to finish

Backend testing helps ensure your application's core functionality works correctly before deploying to production.

Testing API Endpoints with Supertest

Supertest is a popular library for testing HTTP servers in Node.js. It allows you to make HTTP requests to your application and assert the responses.

Setting Up Supertest

npm install --save-dev supertest

Basic API Test Example

// user.test.js
const request = require('supertest');
const app = require('../app'); // Your Express app

describe('User API', () => {
  test('GET /api/users returns all users', async () => {
    const response = await request(app).get('/api/users');
    
    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty('users');
    expect(Array.isArray(response.body.users)).toBe(true);
  });
  
  test('GET /api/users/:id returns a specific user', async () => {
    const response = await request(app).get('/api/users/1');
    
    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty('id', 1);
    expect(response.body).toHaveProperty('username');
  });
  
  test('POST /api/users creates a new user', async () => {
    const userData = {
      username: 'testuser',
      email: 'test@example.com',
      password: 'password123'
    };
    
    const response = await request(app)
      .post('/api/users')
      .send(userData);
    
    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('id');
    expect(response.body.username).toBe('testuser');
    expect(response.body.email).toBe('test@example.com');
    // Password should not be returned
    expect(response.body).not.toHaveProperty('password');
  });
});

Testing Authentication Endpoints

Testing authentication endpoints requires special consideration for handling credentials and tokens.

Testing Registration and Login

describe('Authentication API', () => {
  test('POST /api/auth/register creates a new user', async () => {
    const userData = {
      username: 'newuser',
      email: 'newuser@example.com',
      password: 'securepassword123'
    };
    
    const response = await request(app)
      .post('/api/auth/register')
      .send(userData);
    
    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('message', 'User registered successfully');
    expect(response.body).toHaveProperty('user');
    expect(response.body.user).toHaveProperty('id');
    expect(response.body.user.username).toBe('newuser');
  });
  
  test('POST /api/auth/login authenticates a user', async () => {
    const credentials = {
      email: 'newuser@example.com',
      password: 'securepassword123'
    };
    
    const response = await request(app)
      .post('/api/auth/login')
      .send(credentials);
    
    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty('message', 'Login successful');
    expect(response.body).toHaveProperty('token');
  });
});

Testing Protected Routes

describe('Protected Routes', () => {
  let authToken;
  
  beforeAll(async () => {
    // Login to get a token
    const response = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'newuser@example.com',
        password: 'securepassword123'
      });
    
    authToken = response.body.token;
  });
  
  test('GET /api/profile returns user profile when authenticated', async () => {
    const response = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${authToken}`);
    
    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty('username', 'newuser');
  });
  
  test('GET /api/profile returns 401 when not authenticated', async () => {
    const response = await request(app)
      .get('/api/profile');
    
    expect(response.status).toBe(401);
  });
});

Testing Database Interactions

When testing database interactions, it's important to use a test database or mock the database to avoid affecting production data.

Approaches to Database Testing

  1. Test Database: Use a separate database for testing
  2. In-Memory Database: Use an in-memory database for faster tests
  3. Mocking: Mock database calls to avoid database interaction

Setting Up a Test Database

// In your test setup file
beforeAll(async () => {
  // Connect to test database
  await mongoose.connect(process.env.TEST_DB_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true
  });
});

afterAll(async () => {
  // Disconnect after tests
  await mongoose.connection.close();
});

// Clear database between tests
beforeEach(async () => {
  await mongoose.connection.dropDatabase();
});

Testing Database Models

describe('User Model', () => {
  test('create & save user successfully', async () => {
    const userData = {
      username: 'testuser',
      email: 'test@test.com',
      password: 'Password123'
    };
    
    const validUser = new User(userData);
    const savedUser = await validUser.save();
    
    // Object Id should be defined when successfully saved to MongoDB
    expect(savedUser._id).toBeDefined();
    expect(savedUser.username).toBe(userData.username);
    expect(savedUser.email).toBe(userData.email);
    // Password should be hashed, not plain text
    expect(savedUser.password).not.toBe(userData.password);
  });
  
  test('create user with missing required field fails', async () => {
    const userWithoutRequiredField = new User({ username: 'test' });
    let err;
    
    try {
      await userWithoutRequiredField.save();
    } catch (error) {
      err = error;
    }
    
    expect(err).toBeInstanceOf(mongoose.Error.ValidationError);
  });
});

Integration Testing

Integration tests verify that different parts of your application work together correctly. These tests are valuable for catching issues that might not be apparent when testing components in isolation.

Example: User Registration and Authentication Flow

describe('User Registration and Authentication Flow', () => {
  const userData = {
    username: 'integrationuser',
    email: 'integration@example.com',
    password: 'SecurePass123'
  };
  
  let userId;
  let authToken;
  
  test('register a new user', async () => {
    const response = await request(app)
      .post('/api/auth/register')
      .send(userData);
    
    expect(response.status).toBe(201);
    expect(response.body.user).toHaveProperty('id');
    userId = response.body.user.id;
  });
  
  test('login with the new user', async () => {
    const response = await request(app)
      .post('/api/auth/login')
      .send({
        email: userData.email,
        password: userData.password
      });
    
    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty('token');
    authToken = response.body.token;
  });
  
  test('access protected route with auth token', async () => {
    const response = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${authToken}`);
    
    expect(response.status).toBe(200);
    expect(response.body.username).toBe(userData.username);
  });
  
  test('update user profile', async () => {
    const updateData = {
      bio: 'This is a test user'
    };
    
    const response = await request(app)
      .put(`/api/users/${userId}`)
      .set('Authorization', `Bearer ${authToken}`)
      .send(updateData);
    
    expect(response.status).toBe(200);
    expect(response.body.bio).toBe(updateData.bio);
  });
  
  test('logout and token should be invalid', async () => {
    // Logout
    await request(app)
      .post('/api/auth/logout')
      .set('Authorization', `Bearer ${authToken}`);
    
    // Try to access protected route
    const response = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${authToken}`);
    
    expect(response.status).toBe(401);
  });
});

Advanced Testing Topics

Testing Error Handling

describe('Error Handling', () => {
  test('returns 404 for non-existent routes', async () => {
    const response = await request(app).get('/non-existent-route');
    expect(response.status).toBe(404);
  });
  
  test('returns 400 for bad request format', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ invalidField: 'data' });
    
    expect(response.status).toBe(400);
  });
  
  test('handles server errors gracefully', async () => {
    // Mock a function to throw an error
    jest.spyOn(User, 'findById').mockImplementationOnce(() => {
      throw new Error('Database connection failed');
    });
    
    const response = await request(app).get('/api/users/1');
    
    expect(response.status).toBe(500);
    expect(response.body).toHaveProperty('message');
  });
});

Testing Middleware

describe('Authentication Middleware', () => {
  test('authenticateToken middleware blocks requests without token', async () => {
    const req = { headers: {} };
    const res = {
      status: jest.fn().mockReturnThis(),
      json: jest.fn()
    };
    const next = jest.fn();
    
    const authMiddleware = require('../middleware/auth');
    authMiddleware.authenticateToken(req, res, next);
    
    expect(res.status).toHaveBeenCalledWith(401);
    expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
      message: expect.any(String)
    }));
    expect(next).not.toHaveBeenCalled();
  });
  
  test('authenticateToken middleware allows requests with valid token', async () => {
    // Create a valid token
    const jwt = require('jsonwebtoken');
    const token = jwt.sign({ id: 1 }, process.env.JWT_SECRET);
    
    const req = { 
      headers: { 
        authorization: `Bearer ${token}` 
      } 
    };
    const res = {};
    const next = jest.fn();
    
    const authMiddleware = require('../middleware/auth');
    authMiddleware.authenticateToken(req, res, next);
    
    expect(next).toHaveBeenCalled();
    expect(req.user).toEqual(expect.objectContaining({ id: 1 }));
  });
});

Additional Resources

Practice Assignment

Now it's time to practice testing backend applications! Create comprehensive tests for your authentication system:

  • Write API tests for registration, login, and profile endpoints
  • Test protected routes with and without authentication
  • Create database tests for your user model
  • Implement integration tests for the complete authentication flow
  • Test error handling for various scenarios

Sprint Challenge Preparation

Now that you've completed all four modules in this sprint, it's time to prepare for the sprint challenge. Make sure you understand:

  • Authentication concepts and implementation
  • JWT-based token authentication
  • Writing effective tests for both unit and API functionality
  • Testing database interactions and protected routes
Sprint Challenge