Dev Dissection — Week 6: Testing APIs & UI

Your app is live and working perfectly… until it’s not. In Week 5, you deployed your TODO app to production. This week, we’re building the safety net that prevents bugs from reaching your users: automated testing.

By the end of this lesson, you’ll have comprehensive tests for your backend API and frontend components.

Prerequisites

Make sure you have:

  • Your Week 5 deployed TODO app (backend + frontend)
  • Node.js and npm installed
  • Basic understanding of async/await
  • Familiarity with your API endpoints

Testing Fundamentals: Why Tests Matter

Testing answers the question: “Does my code work as expected?”

Without tests, every change is a gamble. With tests, you can:

  • Catch bugs early – Before they reach production
  • Refactor with confidence – Change code without fear
  • Document behavior – Tests show how your code should work
  • Enable CI/CD – Automated deployment when tests pass

Types of Tests: The Testing Pyramid

Unit Tests (70%)

Test individual functions in isolation

  • Fast – Run in milliseconds
  • Reliable – No external dependencies
  • Focused – Test one thing at a time

Integration Tests (20%)

Test how components work together

  • Realistic – Test actual workflows
  • Database included – Test with real data
  • API endpoints – Full request/response cycle

End-to-End Tests (10%)

Test complete user flows

  • Browser automation – Real user interactions
  • Full stack – Frontend + Backend + Database
  • Slow but thorough – Catches integration issues

For this week, we’ll focus on Unit and Integration tests.

Backend setup:

First of all, we’ll need to do some refactoring. We have to move our todo a routers file and schema to a separate file so that they can be reused. So your index.ts would look like this

import './loadEnv';

import cors from 'cors';
import express from 'express';
import mongoose from 'mongoose';
import { authRouter } from './routes/auth';
import { todoRouter } from './routes/todo';
import { authenticate } from './middleware/auth';

const app = express();
const PORT = process.env.PORT || 4000;

app.use(cors());
app.use(express.json());

// Connect to MongoDB
mongoose
  .connect(process.env.MONGO_URI!)
  .then(() => console.log('Connected to MongoDB', process.env.MONGO_URI!))
  .catch((err) => console.error('MongoDB connection error:', err));

// Auth routes (public)
app.use('/auth', authRouter);
app.use('/todos', authenticate, todoRouter);

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

I’m not going to post all refactor, it’s something you can do as you’ve been following the session. The goal is to focus on working adding tests.

Let’s install the dependencies we need

npm install --save-dev jest @types/jest ts-jest supertest @types/supertest
npm install --save-dev mongodb-memory-server

About the dependencies:

Jest helps us write unit and integration tests, we basically validate if unit ( functions ) and integration ( api response ) are returning proper values and are handling cases like authentication, validation etc. In TDD, you first write out all the cases, values, validations, responses and edge cases that you might run into when building APIs. Then you write the functions, API’s and run those cases against them.

In your backend folder, create jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/src/tests'],
  testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'],
  transform: {
    '^.+\\.ts$': 'ts-jest',
  },
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/index.ts',
    '!src/loadEnv.ts',
  ],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],
  setupFilesAfterEnv: ['<rootDir>/src/tests/setup.ts'],
  testTimeout: 10000,
};

Now, create our tests folder and setup.ts

import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import '../loadEnv';
import express from 'express';
import { authRouter } from '../routes/auth';
import { todoRouter } from '../routes/todo';
import { authenticate } from '../middleware/auth';

let mongod: MongoMemoryServer;

export const createTestApp = () => {
  const app = express();
  app.use(express.json());
  app.use('/auth', authRouter);
  app.use('/todos', authenticate, todoRouter);
  return app;
};

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

  await mongoose.connect(uri);
});

afterAll(async () => {
  // Clean up
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
  await mongod.stop();
});

afterEach(async () => {
  // Clear all collections after each test
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    const collection = collections[key];
    await collection.deleteMany({});
  }
});

We’re basically creating an InMemory mongodb instance that would only save our data to memory, You can also specify a local mongodb instance and test db if you’re tests are huge and are doing data manipulation at large scale. But this case is Todo’s which can be easily handled easily

Unit tests ( making sure functions are returning data as expected )

Create /src/tests/unit/jwt.test.ts

import { generateToken, verifyToken } from '../../utils/jwt';

describe('JWT Utils', () => {
  const mockPayload = {
    userId: '507f1f77bcf86cd799439011',
    email: 'test@example.com',
  };

  describe('generateToken', () => {
    it('should generate a valid JWT token', () => {
      const token = generateToken(mockPayload);

      expect(token).toBeDefined();
      expect(typeof token).toBe('string');
      expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
    });

    it('should generate different tokens for different payloads', () => {
      const token1 = generateToken(mockPayload);
      const token2 = generateToken({
        userId: '507f1f77bcf86cd799439012',
        email: 'test2@example.com',
      });

      expect(token1).not.toBe(token2);
    });
  });

  describe('verifyToken', () => {
    it('should verify a valid token', () => {
      const token = generateToken(mockPayload);
      const decoded = verifyToken(token);

      expect(decoded.userId).toBe(mockPayload.userId);
      expect(decoded.email).toBe(mockPayload.email);
    });

    it('should throw error for invalid token', () => {
      const invalidToken = 'invalid.token.here';

      expect(() => verifyToken(invalidToken)).toThrow();
    });

    it('should throw error for malformed token', () => {
      const malformedToken = 'not-a-jwt-token';

      expect(() => verifyToken(malformedToken)).toThrow();
    });
  });
});

Let’s create a /src/tests/unit/user.test.ts

import { User } from '../../models/user';

describe('User Model', () => {
  describe('Password Hashing', () => {
    it('should hash password before saving', async () => {
      const userData = {
        email: 'test@example.com',
        password: 'plaintext123',
        name: 'Test User',
      };

      const user = await User.create(userData);

      expect(user.password).not.toBe('plaintext123');
      expect(user.password).toMatch(/^\$2[aby]\$\d{1,2}\$.{53}$/); // bcrypt format
    });

    it('should not rehash password if not modified', async () => {
      const user = await User.create({
        email: 'test@example.com',
        password: 'password123',
        name: 'Test User',
      });

      const originalHash = user.password;
      user.name = 'Updated Name';
      await user.save();

      expect(user.password).toBe(originalHash);
    });
  });

  describe('comparePassword', () => {
    it('should return true for correct password', async () => {
      const password = 'password123';
      const user = await User.create({
        email: 'test@example.com',
        password,
        name: 'Test User',
      });

      const isMatch = await user.comparePassword(password);
      expect(isMatch).toBe(true);
    });

    it('should return false for incorrect password', async () => {
      const user = await User.create({
        email: 'test@example.com',
        password: 'password123',
        name: 'Test User',
      });

      const isMatch = await user.comparePassword('wrongpassword');
      expect(isMatch).toBe(false);
    });
  });

  describe('Validation', () => {
    it('should require email, password, and name', async () => {
      const user = new User({});

      await expect(user.save()).rejects.toThrow();
    });

    it('should require unique email', async () => {
      const userData = {
        email: 'test@example.com',
        password: 'password123',
        name: 'Test User',
      };

      await User.create(userData);

      await expect(User.create(userData)).rejects.toThrow();
    });

    it('should require minimum password length', async () => {
      const userData = {
        email: 'test@example.com',
        password: '123', // Too short
        name: 'Test User',
      };

      await expect(User.create(userData)).rejects.toThrow();
    });

    it('should convert email to lowercase', async () => {
      const user = await User.create({
        email: 'TEST@EXAMPLE.COM',
        password: 'password123',
        name: 'Test User',
      });

      expect(user.email).toBe('test@example.com');
    });
  });

  // NOTE: The following edge-case tests are expected to fail until you implement the corresponding logic in the User model
  describe('Edge Cases', () => {
    it('should reject invalid email format', async () => {
      const invalidData = {
        email: 'not-an-email',
        password: 'password123',
        name: 'Test User',
      };

      // Expected to throw a validation error for email format
      await expect(User.create(invalidData)).rejects.toThrow(
        'User validation failed: email: Invalid email format',
      );
    });

    it('should omit password field when converting to JSON', async () => {
      const userData = {
        email: 'json@example.com',
        password: 'password123',
        name: 'JSON User',
      };

      const user = await User.create(userData);
      const output = user.toJSON();

      // Expected password to be removed from JSON output
      expect(output).not.toHaveProperty('password');
    });
  });
});

Integration Tests ( Making sure API Responses are proper + validation etc )

First we’ll create a test express app which will be linked to our routes. It’s a copy of our main express but since we’re doing API routes testing, we’ll reuse existing routes by just importing them which you can see in setup.ts

Let’s create /src/tests/integration/auth.test.ts

import request from 'supertest';
import { User } from '../../models/user';
import { createTestApp } from '../setup';

const app = createTestApp();

describe('Auth Routes', () => {
  // ========================================
  // SIGNUP TESTS
  // ========================================

  describe('POST /auth/register (Signup)', () => {
    const validUserData = {
      email: 'test@example.com',
      password: 'password123',
      name: 'Test User',
    };

    describe('Successful Registration', () => {
      it('should register a new user successfully', async () => {
        const response = await request(app)
          .post('/auth/register')
          .send(validUserData)
          .expect(201);

        expect(response.body).toHaveProperty('token');
        expect(response.body).toHaveProperty('user');
        expect(response.body.user.email).toBe(validUserData.email);
        expect(response.body.user.name).toBe(validUserData.name);
        expect(response.body.user).not.toHaveProperty('password');
      });
    });

    describe('Registration Validation Errors', () => {
      it('should not register user with existing email', async () => {
        // Create user first
        await User.create(validUserData);

        const response = await request(app)
          .post('/auth/register')
          .send(validUserData)
          .expect(400);

        expect(response.body.error).toBe('User already exists');
      });

      it('should validate required fields', async () => {
        const response = await request(app)
          .post('/auth/register')
          .send({})
          .expect(400);

        expect(response.body.error).toBe('All fields are required');
      });

      it('should validate password length', async () => {
        const shortPasswordData = {
          email: 'test@example.com',
          password: '123', // Too short
          name: 'Test User',
        };

        const response = await request(app)
          .post('/auth/register')
          .send(shortPasswordData)
          .expect(400);

        expect(response.body.error).toBe(
          'Password must be at least 6 characters',
        );
      });
    });
  });

  // ========================================
  // LOGIN TESTS
  // ========================================

  describe('POST /auth/login (Login)', () => {
    const existingUser = {
      email: 'test@example.com',
      password: 'password123',
      name: 'Test User',
    };

    beforeEach(async () => {
      await User.create(existingUser);
    });

    describe('Successful Login', () => {
      it('should login with valid credentials', async () => {
        const response = await request(app)
          .post('/auth/login')
          .send({
            email: existingUser.email,
            password: existingUser.password,
          })
          .expect(200);

        expect(response.body).toHaveProperty('token');
        expect(response.body).toHaveProperty('user');
        expect(response.body.user.email).toBe(existingUser.email);
      });
    });

    describe('Login Authentication Errors', () => {
      it('should reject invalid email', async () => {
        const response = await request(app)
          .post('/auth/login')
          .send({
            email: 'wrong@example.com',
            password: existingUser.password,
          })
          .expect(400);

        expect(response.body.error).toBe('Invalid credentials');
      });

      it('should reject invalid password', async () => {
        const response = await request(app)
          .post('/auth/login')
          .send({
            email: existingUser.email,
            password: 'wrongpassword',
          })
          .expect(400);

        expect(response.body.error).toBe('Invalid credentials');
      });
    });

    describe('Login Validation Errors', () => {
      it('should validate required fields', async () => {
        const response = await request(app)
          .post('/auth/login')
          .send({})
          .expect(400);

        expect(response.body.error).toBe('Email and password are required');
      });
    });
  });
});

Now adding tests for our Todos route /src/tests/integration/todo.test.ts

import request from 'supertest';
import { User } from '../../models/user';
import { createTestApp } from '../setup';
import { Todo } from '../../models/todo';
import { generateToken } from '../../utils/jwt';

const app = createTestApp();

const createTestUserWithToken = async (userData = {}) => {
  const existingUser = {
    email: 'test@example.com',
    password: 'password123',
    name: 'Test User',
  };

  const user = await User.create({ ...existingUser, ...userData });

  const token = generateToken({
    email: user.email,
    userId: user.id,
  });

  return { user, token };
};

describe('Todo Routes', () => {
  let userToken: string;
  let userId: string;

  beforeEach(async () => {
    // default user
    const { user, token } = await createTestUserWithToken();
    userToken = token;
    userId = user.id;
  });

  describe('POST /todos', () => {
    it('should create a new todo', async () => {
      const todoData = { task: 'Test todo' };

      const response = await request(app)
        .post('/todos')
        .set('Authorization', `Bearer ${userToken}`)
        .send(todoData)
        .expect(201);

      expect(response.body.task).toBe(todoData.task);
      expect(response.body.completed).toBe(false);
      expect(response.body.user).toBe(userId);
    });

    it('should require authentication', async () => {
      const response = await request(app)
        .post('/todos')
        .send({ task: 'Test todo' })
        .expect(401);

      expect(response.body.error).toBe('Access token required');
    });

    it('should validate task field', async () => {
      const response = await request(app)
        .post('/todos')
        .set('Authorization', `Bearer ${userToken}`)
        .send({})
        .expect(400);

      expect(response.body.error).toBe('Task is required');
    });

    it('should reject invalid token', async () => {
      const response = await request(app)
        .post('/todos')
        .set('Authorization', 'Bearer invalid-token')
        .send({ task: 'Test todo' })
        .expect(401);

      expect(response.body.error).toBe('Invalid or expired token');
    });
  });

  describe('GET /todos', () => {
    it('should get user todos', async () => {
      // Create test todos
      await Todo.create([
        { task: 'Todo 1', user: userId },
        { task: 'Todo 2', user: userId, completed: true },
      ]);

      const response = await request(app)
        .get('/todos')
        .set('Authorization', `Bearer ${userToken}`)
        .expect(200);

      expect(response.body).toHaveLength(2);
      const tasks = response.body.map((todo: any) => todo.task);
      expect(tasks).toEqual(expect.arrayContaining(['Todo 1', 'Todo 2']));
    });

    it('should only return user-specific todos', async () => {
      const { user: otherUser } = await createTestUserWithToken({
        email: 'other@example.com',
      });

      // Create todos for both users
      await Todo.create([
        { task: 'My Todo', user: userId },
        { task: 'Other Todo', user: otherUser.id },
      ]);

      const response = await request(app)
        .get('/todos')
        .set('Authorization', `Bearer ${userToken}`)
        .expect(200);

      expect(response.body).toHaveLength(1);
      expect(response.body[0].task).toBe('My Todo');
    });

    it('should require authentication', async () => {
      const response = await request(app).get('/todos').expect(401);

      expect(response.body.error).toBe('Access token required');
    });
  });

  describe('PUT /todos/:id', () => {
    let todoId: string;

    beforeEach(async () => {
      const todo = await Todo.create({
        task: 'Test todo',
        user: userId,
      });
      todoId = todo.id;
    });

    it('should update todo', async () => {
      const updateData = { completed: true };

      const response = await request(app)
        .put(`/todos/${todoId}`)
        .set('Authorization', `Bearer ${userToken}`)
        .send(updateData)
        .expect(200);

      expect(response.body.completed).toBe(true);
    });

    it('should only update user-owned todos', async () => {
      const { user: otherUser } = await createTestUserWithToken({
        email: 'other@example.com',
      });

      const otherTodo = await Todo.create({
        task: 'Other todo',
        user: otherUser.id,
      });

      const response = await request(app)
        .put(`/todos/${otherTodo.id}`)
        .set('Authorization', `Bearer ${userToken}`)
        .send({ completed: true })
        .expect(404);

      expect(response.body.error).toBe('Todo not found');
    });

    it('should validate todo ID format', async () => {
      const response = await request(app)
        .put('/todos/invalid-id')
        .set('Authorization', `Bearer ${userToken}`)
        .send({ completed: true })
        .expect(400);

      expect(response.body.error).toBe('Invalid ID format');
    });
  });

  describe('DELETE /todos/:id', () => {
    let todoId: string;

    beforeEach(async () => {
      const todo = await Todo.create({
        task: 'Test todo',
        user: userId,
      });
      todoId = todo.id;
    });

    it('should delete todo', async () => {
      await request(app)
        .delete(`/todos/${todoId}`)
        .set('Authorization', `Bearer ${userToken}`)
        .expect(204);

      const deletedTodo = await Todo.findById(todoId);
      expect(deletedTodo).toBeNull();
    });

    it('should only delete user-owned todos', async () => {
      const { user: otherUser } = await createTestUserWithToken({
        email: 'other@example.com',
      });

      const otherTodo = await Todo.create({
        task: 'Other todo',
        user: otherUser.id,
      });

      const response = await request(app)
        .delete(`/todos/${otherTodo.id}`)
        .set('Authorization', `Bearer ${userToken}`)
        .expect(404);

      expect(response.body.error).toBe('Todo not found');
    });

    it('should validate todo ID format', async () => {
      const response = await request(app)
        .delete('/todos/invalid-id')
        .set('Authorization', `Bearer ${userToken}`)
        .expect(400);

      expect(response.body.error).toBe('Invalid ID format');
    });
    // NOTE: The following edge case tests are expected to fail until you handle these cases in your router
    describe('Edge Cases', () => {
      it('should return 400 when attempting to update with no valid fields', async () => {
        // Attempt update with empty body
        const response = await request(app)
          .put(`/todos/${todoId}`)
          .set('Authorization', `Bearer ${userToken}`)
          .send({})
          .expect(400);

        expect(response.body.error).toBe('No fields to update');
      });
    });
  });
});

Update the package.json to have scripts to run our tests

{
  "name": "backend-api",
  "version": "1.0.0",
  "main": "src/index.ts",
  "scripts": {
    "start": "cross-env NODE_ENV=production node dist/index.js",
    "dev": "cross-env NODE_ENV=development ts-node-dev --respawn src/index.ts",
    "build": "tsc",
    "gcp-build": "npm run build",
    "test": "cross-env NODE_ENV=test jest",
    "test:watch": "cross-env NODE_ENV=test jest --watch",
    "test:coverage": "cross-env NODE_ENV=test jest --coverage",
    "test:ci": "cross-env NODE_ENV=test jest --ci --coverage --watchAll=false"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "bcryptjs": "^3.0.2",
    "cors": "^2.8.5",
    "cross-env": "^7.0.3",
    "dotenv": "^16.6.1",
    "express": "^5.1.0",
    "jsonwebtoken": "^9.0.2",
    "mongoose": "^8.15.1"
  },
  "devDependencies": {
    "@types/bcryptjs": "^3.0.0",
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.2",
    "@types/jest": "^30.0.0",
    "@types/jsonwebtoken": "^9.0.10",
    "@types/mongoose": "^5.11.96",
    "@types/node": "^22.15.29",
    "@types/supertest": "^6.0.3",
    "jest": "^30.0.4",
    "mongodb-memory-server": "^10.1.4",
    "supertest": "^7.1.3",
    "ts-jest": "^29.4.0",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.8.3"
  }
}

Let’s run our tests using npm run test

After tests are completed, you’ll immediately see few tests are failing. That’s on purpose. That’s your job to fix and understand why they are failing. My goal is to help you learn it and you’ll learn by doing it. You can also add few edge case tests that I might not have covered.

Frontend Setup

Install Testing Dependencies

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom @types/jest

Configure Jest

Create jest.config.js in your frontend root:

// eslint-disable-next-line @typescript-eslint/no-require-imports
const nextJest = require('next/jest')

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files
  dir: './',
})

// Add any custom config to be passed to Jest
const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
  },
  testEnvironment: 'jest-environment-jsdom',
}

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

Create jest.setup.js:

import '@testing-library/jest-dom'

// Mock localStorage
const localStorageMock = {
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
  clear: jest.fn(),
}
global.localStorage = localStorageMock

// Mock fetch
global.fetch = jest.fn()

Add test scripts to your package.json:

{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
}

Unit Test Example: AuthForm Component

Let’s write a unit test for our AuthForm component to ensure it renders correctly and handles user input.

Create __tests__/components/auth-form.test.tsx:

// Add jest-dom import for custom matchers
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AuthForm } from '@/components/auth-form';
import { useAuth } from '@/lib/auth-context';

// Mock the auth context
jest.mock('@/lib/auth-context', () => ({
  useAuth: jest.fn(),
}));

const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;

describe('AuthForm', () => {
  const mockLogin = jest.fn();
  const mockRegister = jest.fn();
  const mockOnToggleMode = jest.fn();

  beforeEach(() => {
    mockUseAuth.mockReturnValue({
      login: mockLogin,
      register: mockRegister,
      user: null,
      token: null,
      logout: jest.fn(),
      isLoading: false,
    });

    // Clear all mocks
    jest.clearAllMocks();
  });

  it('renders login form correctly and handles user input', async () => {
    const user = userEvent.setup();

    render(<AuthForm mode="login" onToggleMode={mockOnToggleMode} />);

    // Check if login form elements are rendered
    expect(
      screen.getByRole('heading', { name: /sign in/i })
    ).toBeInTheDocument();
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(
      screen.getByRole('button', { name: /sign in/i })
    ).toBeInTheDocument();

    // Check toggle button text
    expect(
      screen.getByText(/don't have an account\? sign up/i)
    ).toBeInTheDocument();

    // Test user input
    const emailInput = screen.getByLabelText(/email/i);
    const passwordInput = screen.getByLabelText(/password/i);

    await user.type(emailInput, 'test@example.com');
    await user.type(passwordInput, 'password123');

    expect(emailInput).toHaveValue('test@example.com');
    expect(passwordInput).toHaveValue('password123');

    // Test form submission
    const submitButton = screen.getByRole('button', { name: /sign in/i });
    await user.click(submitButton);

    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
    });
  });

  it('renders register form correctly and handles user input', async () => {
    const user = userEvent.setup();

    render(<AuthForm mode="register" onToggleMode={mockOnToggleMode} />);

    // Check if register form elements are rendered
    expect(
      screen.getByRole('heading', { name: /create account/i })
    ).toBeInTheDocument();
    expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(
      screen.getByRole('button', { name: /create account/i })
    ).toBeInTheDocument();
    expect(
      screen.getByText(/already have an account\? sign in/i)
    ).toBeInTheDocument();

    // Test user input
    const nameInput = screen.getByLabelText(/full name/i);
    const emailInput = screen.getByLabelText(/email/i);
    const passwordInput = screen.getByLabelText(/password/i);

    await user.type(nameInput, 'Test User');
    await user.type(emailInput, 'register@example.com');
    await user.type(passwordInput, 'registerpass');

    expect(nameInput).toHaveValue('Test User');
    expect(emailInput).toHaveValue('register@example.com');
    expect(passwordInput).toHaveValue('registerpass');

    // Test form submission
    const submitButton = screen.getByRole('button', {
      name: /create account/i,
    });
    await user.click(submitButton);

    await waitFor(() => {
      expect(mockRegister).toHaveBeenCalledWith(
        'register@example.com',
        'registerpass',
        'Test User'
      );
    });
  });

  it('displays error message when login fails', async () => {
    const user = userEvent.setup();
    mockLogin.mockRejectedValueOnce(new Error('Invalid credentials'));

    render(<AuthForm mode="login" onToggleMode={mockOnToggleMode} />);

    const emailInput = screen.getByLabelText(/email/i);
    const passwordInput = screen.getByLabelText(/password/i);
    await user.type(emailInput, 'fail@example.com');
    await user.type(passwordInput, 'wrongpass');
    const submitButton = screen.getByRole('button', { name: /sign in/i });
    await user.click(submitButton);

    await waitFor(() => {
      expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
    });
  });

  it('clears all fields and error when toggling from register to login', async () => {
    const user = userEvent.setup();
    mockRegister.mockRejectedValueOnce(new Error('Registration error'));

    render(<AuthForm mode="register" onToggleMode={mockOnToggleMode} />);

    // Fill out the form and trigger an error
    const nameInput = screen.getByLabelText(/full name/i);
    const emailInput = screen.getByLabelText(/email/i);
    const passwordInput = screen.getByLabelText(/password/i);
    await user.type(nameInput, 'Tricky User');
    await user.type(emailInput, 'tricky@example.com');
    await user.type(passwordInput, 'trickypass');
    const submitButton = screen.getByRole('button', {
      name: /create account/i,
    });
    await user.click(submitButton);
    await waitFor(() => {
      expect(screen.getByText(/registration error/i)).toBeInTheDocument();
    });

    // Toggle to login mode
    const toggleButton = screen.getByRole('button', {
      name: /already have an account\? sign in/i,
    });
    await user.click(toggleButton);

    // All fields and error should be cleared (this will fail with current implementation)
    expect(screen.getByLabelText(/email/i)).toHaveValue('');
    expect(screen.getByLabelText(/password/i)).toHaveValue('');
    expect(screen.queryByLabelText(/full name/i)).not.toBeInTheDocument();
    expect(screen.queryByText(/registration error/i)).not.toBeInTheDocument();
  });
});

Integration Test Example: Complete Authentication Flow

Let’s write an integration test that tests the complete authentication flow with the API layer.

Create __tests__/integration/auth-flow.test.tsx:

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AuthProvider } from '@/lib/auth-context'
import { AuthForm } from '@/components/auth-form'

// Mock fetch for API calls
const mockFetch = jest.fn()
global.fetch = mockFetch

// Mock localStorage
const mockLocalStorage = {
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
  clear: jest.fn(),
}
Object.defineProperty(window, 'localStorage', {
  value: mockLocalStorage
})

describe('Authentication Flow Integration', () => {
  beforeEach(() => {
    jest.clearAllMocks()
    mockLocalStorage.getItem.mockReturnValue(null)
  })

  it('successfully logs in user and stores token', async () => {
    const user = userEvent.setup()
    
    // Mock successful login API response
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        token: 'mock-jwt-token',
        user: {
          id: '1',
          email: 'test@example.com',
          name: 'Test User'
        },
        message: 'Login successful'
      })
    })

    const mockOnToggleMode = jest.fn()

    render(
      <AuthProvider>
        <AuthForm mode="login" onToggleMode={mockOnToggleMode} />
      </AuthProvider>
    )

    // Fill in login form
    const emailInput = screen.getByLabelText(/email/i)
    const passwordInput = screen.getByLabelText(/password/i)
    const submitButton = screen.getByRole('button', { name: /sign in/i })

    await user.type(emailInput, 'test@example.com')
    await user.type(passwordInput, 'password123')
    await user.click(submitButton)

    // Wait for API call to complete
    await waitFor(() => {
      expect(mockFetch).toHaveBeenCalledWith(
        'http://localhost:4000/auth/login',
        expect.objectContaining({
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            email: 'test@example.com',
            password: 'password123'
          })
        })
      )
    })

    // Verify localStorage was called to store token and user
    await waitFor(() => {
      expect(mockLocalStorage.setItem).toHaveBeenCalledWith('token', 'mock-jwt-token')
      expect(mockLocalStorage.setItem).toHaveBeenCalledWith('user', JSON.stringify({
        id: '1',
        email: 'test@example.com',
        name: 'Test User'
      }))
    })
  })

  it('handles login errors appropriately', async () => {
    const user = userEvent.setup()
    
    // Mock failed login API response
    mockFetch.mockResolvedValueOnce({
      ok: false,
      json: async () => ({
        error: 'Invalid credentials'
      })
    })

    const mockOnToggleMode = jest.fn()

    render(
      <AuthProvider>
        <AuthForm mode="login" onToggleMode={mockOnToggleMode} />
      </AuthProvider>
    )

    // Fill in login form with invalid credentials
    const emailInput = screen.getByLabelText(/email/i)
    const passwordInput = screen.getByLabelText(/password/i)
    const submitButton = screen.getByRole('button', { name: /sign in/i })

    await user.type(emailInput, 'invalid@example.com')
    await user.type(passwordInput, 'wrongpassword')
    await user.click(submitButton)

    // Wait for error to be displayed
    await waitFor(() => {
      expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
    })

    // Verify localStorage was not called
    expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
  })
})

Let’s run our tests using npm run test

You’ll notice that all tests fail except one, It’s pretty simple to solve. Again the goal is that you understand how it works and why it’s failing

What You’ve Learned

  • Unit Testing: Test individual components & functions in isolation
  • Integration Testing: Test complete user flows & API Responses
  • Mocking: Mock external dependencies and APIs
  • User-Centric Testing: Focus on user interactions, not implementation
  • Error Handling: Test both success and failure scenarios

Your App is now tested and ready for confident deployments. These tests will catch any issues before deployment. It helps us make sure that nothing is breaking and everything is working as expected when you do refactor, improvements and new features that affect other features.

Next Up: Docker Fundamentals

Your app is running locally — but what happens when you need to deploy it or share it with your team? Next week, we’ll explore containerization with Docker:

  • What is Docker and why containerization matters
  • Dockerize your backend API with a Dockerfile
  • Containerize your frontend application
  • Use Docker Compose to run your full stack together

You’ll package your entire application into portable containers that run consistently anywhere — no more “it works on my machine” problems.

“Build once, run anywhere.” Let’s make your app truly portable