Testing Best Practices: From Unit Tests to E2E Testing
Testing is a crucial aspect of software development that ensures reliability and maintainability. Let's explore comprehensive testing strategies and best practices.
Unit Testing
1. Jest Configuration
Set up Jest for TypeScript projects:
// jest.config.ts import type { Config } from "@jest/types"; const config: Config.InitialOptions = { preset: "ts-jest", testEnvironment: "node", roots: ["<rootDir>/src"], transform: { "^.+\\.tsx?$": "ts-jest", }, testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], collectCoverage: true, coverageDirectory: "coverage", coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, setupFilesAfterEnv: ["<rootDir>/src/test/setup.ts"], }; export default config;
2. Unit Test Examples
Write comprehensive unit tests:
// src/services/__tests__/user.service.test.ts import { UserService } from "../user.service"; import { UserRepository } from "../user.repository"; import { createMock } from "@golevelup/ts-jest"; describe("UserService", () => { let userService: UserService; let userRepository: jest.Mocked<UserRepository>; beforeEach(() => { userRepository = createMock<UserRepository>(); userService = new UserService(userRepository); }); describe("createUser", () => { const userData = { email: "test@example.com", password: "password123", name: "Test User", }; it("should create a new user successfully", async () => { const hashedPassword = "hashed_password"; const createdUser = { ...userData, id: "1", password: hashedPassword }; jest.spyOn(userService, "hashPassword").mockResolvedValue(hashedPassword); userRepository.create.mockResolvedValue(createdUser); const result = await userService.createUser(userData); expect(result).toEqual({ id: "1", email: userData.email, name: userData.name, }); expect(userRepository.create).toHaveBeenCalledWith({ ...userData, password: hashedPassword, }); }); it("should throw error if email already exists", async () => { userRepository.findByEmail.mockResolvedValue({ id: "2", email: userData.email, name: "Existing User", }); await expect(userService.createUser(userData)).rejects.toThrow( "Email already exists" ); }); }); describe("validatePassword", () => { it("should return true for valid password", async () => { const password = "password123"; const hashedPassword = "hashed_password"; jest.spyOn(userService, "comparePasswords").mockResolvedValue(true); const result = await userService.validatePassword( password, hashedPassword ); expect(result).toBe(true); }); it("should return false for invalid password", async () => { const password = "wrong_password"; const hashedPassword = "hashed_password"; jest.spyOn(userService, "comparePasswords").mockResolvedValue(false); const result = await userService.validatePassword( password, hashedPassword ); expect(result).toBe(false); }); }); });
Integration Testing
1. Supertest Configuration
Set up integration tests with Supertest:
// src/test/setup.ts import { app } from "../app"; import { createConnection, getConnection } from "typeorm"; import request from "supertest"; beforeAll(async () => { await createConnection({ type: "postgres", host: "localhost", port: 5432, username: "test", password: "test", database: "test_db", entities: ["src/entities/*.ts"], synchronize: true, }); }); afterAll(async () => { const connection = getConnection(); await connection.close(); }); // src/routes/__tests__/auth.routes.test.ts describe("Auth Routes", () => { const agent = request(app); describe("POST /auth/login", () => { it("should login successfully with valid credentials", async () => { const response = await agent.post("/auth/login").send({ email: "test@example.com", password: "password123", }); expect(response.status).toBe(200); expect(response.body).toHaveProperty("token"); expect(response.body.user).toHaveProperty("email"); }); it("should return 401 with invalid credentials", async () => { const response = await agent.post("/auth/login").send({ email: "test@example.com", password: "wrong_password", }); expect(response.status).toBe(401); expect(response.body).toHaveProperty("error"); }); }); describe("GET /auth/me", () => { it("should return user profile with valid token", async () => { const token = "valid_token"; const response = await agent .get("/auth/me") .set("Authorization", `Bearer ${token}`); expect(response.status).toBe(200); expect(response.body).toHaveProperty("email"); }); it("should return 401 without token", async () => { const response = await agent.get("/auth/me"); expect(response.status).toBe(401); }); }); });
E2E Testing
1. Cypress Configuration
Set up Cypress for E2E testing:
// cypress.config.ts import { defineConfig } from "cypress"; export default defineConfig({ e2e: { baseUrl: "http://localhost:3000", supportFile: "cypress/support/e2e.ts", specPattern: "cypress/e2e/**/*.cy.ts", viewportWidth: 1280, viewportHeight: 720, video: false, screenshotOnRunFailure: true, defaultCommandTimeout: 10000, }, }); // cypress/support/commands.ts Cypress.Commands.add("login", (email: string, password: string) => { cy.request({ method: "POST", url: "/api/auth/login", body: { email, password }, }).then((response) => { window.localStorage.setItem("token", response.body.token); }); }); // cypress/e2e/auth.cy.ts describe("Authentication Flow", () => { beforeEach(() => { cy.visit("/login"); }); it("should login successfully", () => { cy.get("[data-cy=email-input]").type("test@example.com"); cy.get("[data-cy=password-input]").type("password123"); cy.get("[data-cy=login-button]").click(); cy.url().should("include", "/dashboard"); cy.get("[data-cy=user-menu]").should("be.visible"); }); it("should show error with invalid credentials", () => { cy.get("[data-cy=email-input]").type("test@example.com"); cy.get("[data-cy=password-input]").type("wrong_password"); cy.get("[data-cy=login-button]").click(); cy.get("[data-cy=error-message]") .should("be.visible") .and("contain", "Invalid credentials"); }); it("should navigate to forgot password", () => { cy.get("[data-cy=forgot-password-link]").click(); cy.url().should("include", "/forgot-password"); }); });
Test Utilities
1. Test Helpers
Create reusable test utilities:
// src/test/helpers.ts import { User } from "../entities/user.entity"; import { getRepository } from "typeorm"; import * as jwt from "jsonwebtoken"; export class TestHelpers { static async createTestUser(overrides: Partial<User> = {}): Promise<User> { const userRepository = getRepository(User); const user = userRepository.create({ email: "test@example.com", password: "hashed_password", name: "Test User", ...overrides, }); return userRepository.save(user); } static generateTestToken(user: User): string { return jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, { expiresIn: "1h", }); } static async cleanupDatabase(): Promise<void> { const entities = getConnection().entityMetadatas; for (const entity of entities) { const repository = getRepository(entity.name); await repository.query(`TRUNCATE ${entity.tableName} CASCADE;`); } } } // src/test/factories.ts import { Factory } from "fishery"; import { User } from "../entities/user.entity"; import faker from "@faker-js/faker"; export const userFactory = Factory.define<User>(() => ({ id: faker.datatype.uuid(), email: faker.internet.email(), password: faker.internet.password(), name: faker.name.findName(), createdAt: faker.date.past(), updatedAt: faker.date.recent(), }));
Mocking
1. Mock Implementations
Create comprehensive mocks:
// src/test/mocks/repositories.ts import { DeepPartial } from "typeorm"; import { User } from "../../entities/user.entity"; export class MockUserRepository { private users: User[] = []; async find(): Promise<User[]> { return this.users; } async findOne(id: string): Promise<User | undefined> { return this.users.find((user) => user.id === id); } async create(userData: DeepPartial<User>): Promise<User> { const user = { id: Math.random().toString(), createdAt: new Date(), updatedAt: new Date(), ...userData, } as User; this.users.push(user); return user; } async save(user: User): Promise<User> { const index = this.users.findIndex((u) => u.id === user.id); if (index >= 0) { this.users[index] = user; } else { this.users.push(user); } return user; } async delete(id: string): Promise<void> { this.users = this.users.filter((user) => user.id !== id); } } // src/test/mocks/services.ts export class MockEmailService { async sendWelcomeEmail(to: string, name: string): Promise<void> { // Mock implementation } async sendPasswordReset(to: string, token: string): Promise<void> { // Mock implementation } }
Best Practices
- Write Descriptive Tests: Use clear test descriptions
- Follow AAA Pattern: Arrange, Act, Assert
- Maintain Test Independence: Each test should be independent
- Mock External Dependencies: Use mocks for external services
- Test Edge Cases: Include error scenarios and edge cases
- Maintain Test Coverage: Aim for high test coverage
- Use Test Factories: Create reusable test data
- Regular Test Maintenance: Keep tests up to date
Implementation Checklist
- Set up testing framework
- Configure test environment
- Write unit tests
- Implement integration tests
- Set up E2E testing
- Create test utilities
- Configure CI/CD for tests
- Monitor test coverage
Conclusion
Comprehensive testing is essential for maintaining reliable applications. Focus on writing maintainable tests and following testing best practices.