Testing

Testing Best Practices: From Unit Tests to E2E Testing

·6 min read
Testing Best Practices: From Unit Tests to E2E Testing

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

  1. Write Descriptive Tests: Use clear test descriptions
  2. Follow AAA Pattern: Arrange, Act, Assert
  3. Maintain Test Independence: Each test should be independent
  4. Mock External Dependencies: Use mocks for external services
  5. Test Edge Cases: Include error scenarios and edge cases
  6. Maintain Test Coverage: Aim for high test coverage
  7. Use Test Factories: Create reusable test data
  8. Regular Test Maintenance: Keep tests up to date

Implementation Checklist

  1. Set up testing framework
  2. Configure test environment
  3. Write unit tests
  4. Implement integration tests
  5. Set up E2E testing
  6. Create test utilities
  7. Configure CI/CD for tests
  8. Monitor test coverage

Conclusion

Comprehensive testing is essential for maintaining reliable applications. Focus on writing maintainable tests and following testing best practices.

Resources