Skip to main content

06 - Testing and Code Quality with Copilot

Generate tests with Copilot: unit tests, integration tests, guided refactoring, assisted debugging and strategies for optimal code coverage.

What do you think about this article?

At a Glance

Generate tests with Copilot: unit tests, integration tests, guided refactoring, assisted debugging and strategies for optimal code coverage.

Testing and Code Quality with Copilot

Tests are the foundation of a maintainable project. They're not just a safety net against bugs, but also executable documentation that describes how the code should behave. GitHub Copilot can drastically accelerate test writing, from TDD to guided refactoring, from assisted debugging to edge case coverage.

In this article, we'll explore how to leverage Copilot for every level of the testing pyramid, with specific prompts, practical examples, and best practices for obtaining robust and maintainable tests.

Series Overview

#ArticleFocus
1Foundation and MindsetSetup and mindset
2Ideation and RequirementsFrom idea to MVP
3Backend ArchitectureAPI and database
4Frontend StructureUI and components
5Prompt EngineeringPrompts and MCP Agents
6You are here - Testing and QualityUnit, integration, E2E
7DocumentationREADME, API docs, ADR
8Deploy and DevOpsDocker, CI/CD
9EvolutionScalability and maintenance

The Testing Pyramid

The testing pyramid defines the optimal strategy to balance speed, coverage, and maintainability. Copilot can help you at all levels.

Testing Pyramid

                    /\
                   /  \
                  / E2E\           - Few (5-10%)
                 /------\            Slow, expensive, fragile
                /        \           Test complete user flows
               /Integration\      - Moderate (15-25%)
              /------------\        Medium speed
             /              \       Test interactions between components
            /   Unit Tests   \    - Many (70-80%)
           /------------------\     Fast, isolated, stable
          /                    \    Test individual units
         /______________________\

Characteristics by Level

LevelSpeedIsolationCostConfidence
Unit~1msHighLowInternal logic
Integration~100msMediumMediumInteractions
E2E~5sLowHighComplete system

Unit Tests: The Base of the Pyramid

Unit tests verify individual functions or classes in complete isolation. They're fast, reliable, and provide immediate feedback during development.

Prompts for Generating Unit Tests

Prompt - Generate Comprehensive Unit Tests
Generate comprehensive unit tests for this service:

```typescript
[PASTE SERVICE CODE HERE]
```

TESTING REQUIREMENTS:
- Framework: Jest with TypeScript
- Coverage target: >90% for this critical service
- Mock ALL external dependencies (repositories, external services)

TEST CATEGORIES TO COVER:
1. Happy Path: Normal successful operations
2. Validation: Invalid inputs, edge cases
3. Error Handling: Expected errors are thrown correctly
4. Edge Cases: Empty arrays, null values, boundary conditions
5. State Changes: Verify side effects (calls to dependencies)

OUTPUT FORMAT:
- Complete test file with all imports
- Use describe/it blocks with clear descriptions
- Include beforeEach for setup
- Group tests by method
- Use AAA pattern (Arrange, Act, Assert)
- Add comments explaining non-obvious test cases

NAMING CONVENTION:
- Describe: "ServiceName"
- Nested describe: "methodName"
- It: "should [expected behavior] when [condition]"

Complete Example: UserService Unit Tests

TypeScript - user.service.spec.ts
import {{ '{' }} UserService {{ '}' }} from './user.service';
import {{ '{' }} UserRepository {{ '}' }} from './user.repository';
import {{ '{' }} EmailService {{ '}' }} from '@shared/services/email.service';
import {{ '{' }} ValidationError, NotFoundError, ConflictError {{ '}' }} from '@shared/errors';
import {{ '{' }} createUserFixture, validUserDto {{ '}' }} from './fixtures/user.fixtures';

describe('UserService', () => {{ '{' }}
  let service: UserService;
  let mockUserRepository: jest.Mocked<UserRepository>;
  let mockEmailService: jest.Mocked<EmailService>;

  beforeEach(() => {{ '{' }}
    // Create fresh mocks for each test
    mockUserRepository = {{ '{' }}
      findById: jest.fn(),
      findByEmail: jest.fn(),
      create: jest.fn(),
      update: jest.fn(),
      softDelete: jest.fn(),
      findAll: jest.fn(),
    {{ '}' }} as any;

    mockEmailService = {{ '{' }}
      sendWelcomeEmail: jest.fn(),
      sendPasswordResetEmail: jest.fn(),
    {{ '}' }} as any;

    service = new UserService(mockUserRepository, mockEmailService);
  {{ '}' }});

  afterEach(() => {{ '{' }}
    jest.clearAllMocks();
  {{ '}' }});

  // ===============================================================
  // CREATE USER
  // ===============================================================
  describe('createUser', () => {{ '{' }}
    describe('happy path', () => {{ '{' }}
      it('should create user and send welcome email when valid data provided', async () => {{ '{' }}
        // Arrange
        const dto = validUserDto;
        const expectedUser = createUserFixture({{ '{' }} id: 'user-123', ...dto {{ '}' }});
        mockUserRepository.findByEmail.mockResolvedValue(null);
        mockUserRepository.create.mockResolvedValue(expectedUser);
        mockEmailService.sendWelcomeEmail.mockResolvedValue(undefined);

        // Act
        const result = await service.createUser(dto);

        // Assert
        expect(result.id).toBe('user-123');
        expect(result.email).toBe(dto.email);
        expect(mockUserRepository.create).toHaveBeenCalledWith(
          expect.objectContaining({{ '{' }}
            name: dto.name,
            email: dto.email,
            // Password should be hashed, not plain text
            password: expect.not.stringContaining(dto.password),
          {{ '}' }})
        );
        expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
          expectedUser.email,
          expectedUser.name
        );
      {{ '}' }});

      it('should hash password before saving', async () => {{ '{' }}
        // Arrange
        const dto = {{ '{' }} ...validUserDto, password: 'PlainPassword123!' {{ '}' }};
        mockUserRepository.findByEmail.mockResolvedValue(null);
        mockUserRepository.create.mockResolvedValue(createUserFixture());

        // Act
        await service.createUser(dto);

        // Assert
        const createCall = mockUserRepository.create.mock.calls[0][0];
        expect(createCall.password).not.toBe('PlainPassword123!');
        expect(createCall.password.length).toBeGreaterThan(50); // bcrypt hash length
      {{ '}' }});
    {{ '}' }});

    describe('validation errors', () => {{ '{' }}
      it('should throw ConflictError when email already exists', async () => {{ '{' }}
        // Arrange
        const existingUser = createUserFixture({{ '{' }} email: 'exists@test.com' {{ '}' }});
        mockUserRepository.findByEmail.mockResolvedValue(existingUser);

        // Act & Assert
        await expect(
          service.createUser({{ '{' }} ...validUserDto, email: 'exists@test.com' {{ '}' }})
        ).rejects.toThrow(ConflictError);

        expect(mockUserRepository.create).not.toHaveBeenCalled();
        expect(mockEmailService.sendWelcomeEmail).not.toHaveBeenCalled();
      {{ '}' }});

      it('should throw ValidationError when password is too weak', async () => {{ '{' }}
        // Arrange
        mockUserRepository.findByEmail.mockResolvedValue(null);

        // Act & Assert
        await expect(
          service.createUser({{ '{' }} ...validUserDto, password: '123' {{ '}' }})
        ).rejects.toThrow(ValidationError);
      {{ '}' }});

      it('should throw ValidationError when email format is invalid', async () => {{ '{' }}
        await expect(
          service.createUser({{ '{' }} ...validUserDto, email: 'not-an-email' {{ '}' }})
        ).rejects.toThrow(ValidationError);
      {{ '}' }});
    {{ '}' }});

    describe('edge cases', () => {{ '{' }}
      it('should trim whitespace from email', async () => {{ '{' }}
        // Arrange
        mockUserRepository.findByEmail.mockResolvedValue(null);
        mockUserRepository.create.mockResolvedValue(createUserFixture());

        // Act
        await service.createUser({{ '{' }}
          ...validUserDto,
          email: '  user@test.com  '
        {{ '}' }});

        // Assert
        expect(mockUserRepository.findByEmail).toHaveBeenCalledWith('user@test.com');
      {{ '}' }});

      it('should normalize email to lowercase', async () => {{ '{' }}
        // Arrange
        mockUserRepository.findByEmail.mockResolvedValue(null);
        mockUserRepository.create.mockResolvedValue(createUserFixture());

        // Act
        await service.createUser({{ '{' }}
          ...validUserDto,
          email: 'User@TEST.com'
        {{ '}' }});

        // Assert
        expect(mockUserRepository.findByEmail).toHaveBeenCalledWith('user@test.com');
      {{ '}' }});
    {{ '}' }});
  {{ '}' }});

  // ===============================================================
  // GET USER BY ID
  // ===============================================================
  describe('getUserById', () => {{ '{' }}
    it('should return user when found', async () => {{ '{' }}
      // Arrange
      const user = createUserFixture({{ '{' }} id: 'user-123' {{ '}' }});
      mockUserRepository.findById.mockResolvedValue(user);

      // Act
      const result = await service.getUserById('user-123');

      // Assert
      expect(result).toEqual(user);
      expect(mockUserRepository.findById).toHaveBeenCalledWith('user-123');
    {{ '}' }});

    it('should throw NotFoundError when user does not exist', async () => {{ '{' }}
      // Arrange
      mockUserRepository.findById.mockResolvedValue(null);

      // Act & Assert
      await expect(service.getUserById('non-existent'))
        .rejects.toThrow(NotFoundError);
    {{ '}' }});

    it('should throw ValidationError when id is empty', async () => {{ '{' }}
      await expect(service.getUserById('')).rejects.toThrow(ValidationError);
    {{ '}' }});
  {{ '}' }});

  // ===============================================================
  // UPDATE USER
  // ===============================================================
  describe('updateUser', () => {{ '{' }}
    it('should update user fields', async () => {{ '{' }}
      // Arrange
      const existingUser = createUserFixture({{ '{' }} id: 'user-123', name: 'Old Name' {{ '}' }});
      const updatedUser = {{ '{' }} ...existingUser, name: 'New Name' {{ '}' }};
      mockUserRepository.findById.mockResolvedValue(existingUser);
      mockUserRepository.update.mockResolvedValue(updatedUser);

      // Act
      const result = await service.updateUser('user-123', {{ '{' }} name: 'New Name' {{ '}' }});

      // Assert
      expect(result.name).toBe('New Name');
      expect(mockUserRepository.update).toHaveBeenCalledWith('user-123', {{ '{' }} name: 'New Name' {{ '}' }});
    {{ '}' }});

    it('should throw ConflictError when updating to existing email', async () => {{ '{' }}
      // Arrange
      const existingUser = createUserFixture({{ '{' }} id: 'user-123' {{ '}' }});
      const otherUser = createUserFixture({{ '{' }} id: 'user-456', email: 'taken@test.com' {{ '}' }});
      mockUserRepository.findById.mockResolvedValue(existingUser);
      mockUserRepository.findByEmail.mockResolvedValue(otherUser);

      // Act & Assert
      await expect(
        service.updateUser('user-123', {{ '{' }} email: 'taken@test.com' {{ '}' }})
      ).rejects.toThrow(ConflictError);
    {{ '}' }});

    it('should allow updating to same email (no change)', async () => {{ '{' }}
      // Arrange
      const existingUser = createUserFixture({{ '{' }} id: 'user-123', email: 'same@test.com' {{ '}' }});
      mockUserRepository.findById.mockResolvedValue(existingUser);
      mockUserRepository.findByEmail.mockResolvedValue(existingUser); // Same user
      mockUserRepository.update.mockResolvedValue(existingUser);

      // Act & Assert - should not throw
      await expect(
        service.updateUser('user-123', {{ '{' }} email: 'same@test.com' {{ '}' }})
      ).resolves.not.toThrow();
    {{ '}' }});
  {{ '}' }});

  // ===============================================================
  // SOFT DELETE USER
  // ===============================================================
  describe('softDeleteUser', () => {{ '{' }}
    it('should soft delete existing user', async () => {{ '{' }}
      // Arrange
      const user = createUserFixture({{ '{' }} id: 'user-123' {{ '}' }});
      mockUserRepository.findById.mockResolvedValue(user);
      mockUserRepository.softDelete.mockResolvedValue(undefined);

      // Act
      await service.softDeleteUser('user-123');

      // Assert
      expect(mockUserRepository.softDelete).toHaveBeenCalledWith('user-123');
    {{ '}' }});

    it('should throw NotFoundError when user does not exist', async () => {{ '{' }}
      // Arrange
      mockUserRepository.findById.mockResolvedValue(null);

      // Act & Assert
      await expect(service.softDeleteUser('non-existent'))
        .rejects.toThrow(NotFoundError);
      expect(mockUserRepository.softDelete).not.toHaveBeenCalled();
    {{ '}' }});
  {{ '}' }});
{{ '}' }});

Integration Tests: Testing Interactions

Integration tests verify that components work correctly together. They test the complete API, including routing, middleware, validation, and database access.

Prompt for Integration Tests

Prompt - Generate Integration Tests
Generate integration tests for this API:

ENDPOINTS:
- POST   /api/users      - Create user (requires auth)
- GET    /api/users/:id  - Get user by ID
- PATCH  /api/users/:id  - Update user (owner only)
- DELETE /api/users/:id  - Soft delete (admin only)

TESTING REQUIREMENTS:
- Framework: Jest + Supertest
- Database: Test PostgreSQL (use transactions, rollback after each test)
- Auth: JWT tokens (mock or real test tokens)

TEST SCENARIOS FOR EACH ENDPOINT:
1. Success case with valid request
2. Validation errors (400) - invalid body, missing fields
3. Authentication errors (401) - missing/invalid token
4. Authorization errors (403) - wrong role/permissions
5. Not found errors (404) - resource doesn't exist
6. Conflict errors (409) - duplicate resources

SETUP/TEARDOWN:
- beforeAll: Create test database, run migrations
- beforeEach: Start transaction
- afterEach: Rollback transaction
- afterAll: Close database connection

Include helper functions for:
- Creating authenticated requests
- Generating test users
- Cleaning up test data

Complete Example: Users API Integration Tests

TypeScript - users.integration.spec.ts
import request from 'supertest';
import {{ '{' }} app {{ '}' }} from '../app';
import {{ '{' }} db {{ '}' }} from '../database';
import {{ '{' }} createTestUser, getAuthToken, cleanupTestData {{ '}' }} from './helpers';
import {{ '{' }} UserRole {{ '}' }} from '@shared/types';

describe('Users API Integration Tests', () => {{ '{' }}
  let adminToken: string;
  let userToken: string;
  let testUserId: string;

  beforeAll(async () => {{ '{' }}
    await db.connect();
    await db.migrate();

    // Create test users and get tokens
    const admin = await createTestUser({{ '{' }} role: UserRole.ADMIN {{ '}' }});
    const user = await createTestUser({{ '{' }} role: UserRole.USER {{ '}' }});

    adminToken = await getAuthToken(admin);
    userToken = await getAuthToken(user);
    testUserId = user.id;
  {{ '}' }});

  afterAll(async () => {{ '{' }}
    await cleanupTestData();
    await db.disconnect();
  {{ '}' }});

  // ===============================================================
  // POST /api/users - Create User
  // ===============================================================
  describe('POST /api/users', () => {{ '{' }}
    const validPayload = {{ '{' }}
      name: 'Integration Test User',
      email: 'integration@test.com',
      password: 'SecurePass123!',
    {{ '}' }};

    describe('success cases', () => {{ '{' }}
      it('should create user with valid data (201)', async () => {{ '{' }}
        const response = await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
          .send({{ '{' }} ...validPayload, email: `unique-${{ '{' }}Date.now(){{ '}' }}@test.com` {{ '}' }});

        expect(response.status).toBe(201);
        expect(response.body.data).toMatchObject({{ '{' }}
          name: validPayload.name,
          email: expect.stringContaining('@test.com'),
        {{ '}' }});
        expect(response.body.data.password).toBeUndefined(); // Never return password
        expect(response.body.data.id).toBeDefined();
      {{ '}' }});
    {{ '}' }});

    describe('validation errors (400)', () => {{ '{' }}
      it('should return 400 when email is invalid', async () => {{ '{' }}
        const response = await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
          .send({{ '{' }} ...validPayload, email: 'not-an-email' {{ '}' }});

        expect(response.status).toBe(400);
        expect(response.body.error.code).toBe('VALIDATION_ERROR');
        expect(response.body.error.details).toContainEqual(
          expect.objectContaining({{ '{' }} field: 'email' {{ '}' }})
        );
      {{ '}' }});

      it('should return 400 when password is too weak', async () => {{ '{' }}
        const response = await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
          .send({{ '{' }} ...validPayload, password: '123' {{ '}' }});

        expect(response.status).toBe(400);
        expect(response.body.error.details).toContainEqual(
          expect.objectContaining({{ '{' }} field: 'password' {{ '}' }})
        );
      {{ '}' }});

      it('should return 400 when required fields are missing', async () => {{ '{' }}
        const response = await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
          .send({{ '{' }}{{ '}' }}); // Empty body

        expect(response.status).toBe(400);
      {{ '}' }});
    {{ '}' }});

    describe('authentication errors (401)', () => {{ '{' }}
      it('should return 401 without auth token', async () => {{ '{' }}
        const response = await request(app)
          .post('/api/users')
          .send(validPayload);

        expect(response.status).toBe(401);
        expect(response.body.error.code).toBe('UNAUTHORIZED');
      {{ '}' }});

      it('should return 401 with invalid token', async () => {{ '{' }}
        const response = await request(app)
          .post('/api/users')
          .set('Authorization', 'Bearer invalid-token')
          .send(validPayload);

        expect(response.status).toBe(401);
      {{ '}' }});

      it('should return 401 with expired token', async () => {{ '{' }}
        const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Expired
        const response = await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${{ '{' }}expiredToken{{ '}' }}`)
          .send(validPayload);

        expect(response.status).toBe(401);
      {{ '}' }});
    {{ '}' }});

    describe('conflict errors (409)', () => {{ '{' }}
      it('should return 409 when email already exists', async () => {{ '{' }}
        // First, create a user
        const email = `conflict-${{ '{' }}Date.now(){{ '}' }}@test.com`;
        await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
          .send({{ '{' }} ...validPayload, email {{ '}' }});

        // Try to create with same email
        const response = await request(app)
          .post('/api/users')
          .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`)
          .send({{ '{' }} ...validPayload, email {{ '}' }});

        expect(response.status).toBe(409);
        expect(response.body.error.code).toBe('CONFLICT');
      {{ '}' }});
    {{ '}' }});
  {{ '}' }});

  // ===============================================================
  // GET /api/users/:id - Get User by ID
  // ===============================================================
  describe('GET /api/users/:id', () => {{ '{' }}
    it('should return user when found (200)', async () => {{ '{' }}
      const response = await request(app)
        .get(`/api/users/${{ '{' }}testUserId{{ '}' }}`)
        .set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);

      expect(response.status).toBe(200);
      expect(response.body.data.id).toBe(testUserId);
      expect(response.body.data.password).toBeUndefined();
    {{ '}' }});

    it('should return 404 when user not found', async () => {{ '{' }}
      const response = await request(app)
        .get('/api/users/non-existent-id')
        .set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);

      expect(response.status).toBe(404);
      expect(response.body.error.code).toBe('NOT_FOUND');
    {{ '}' }});

    it('should return 400 for invalid UUID format', async () => {{ '{' }}
      const response = await request(app)
        .get('/api/users/not-a-uuid')
        .set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);

      expect(response.status).toBe(400);
    {{ '}' }});
  {{ '}' }});

  // ===============================================================
  // DELETE /api/users/:id - Soft Delete (Admin Only)
  // ===============================================================
  describe('DELETE /api/users/:id', () => {{ '{' }}
    it('should soft delete user when admin (204)', async () => {{ '{' }}
      // Create user to delete
      const userToDelete = await createTestUser();

      const response = await request(app)
        .delete(`/api/users/${{ '{' }}userToDelete.id{{ '}' }}`)
        .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`);

      expect(response.status).toBe(204);

      // Verify user is soft deleted (can't fetch)
      const getResponse = await request(app)
        .get(`/api/users/${{ '{' }}userToDelete.id{{ '}' }}`)
        .set('Authorization', `Bearer ${{ '{' }}adminToken{{ '}' }}`);

      expect(getResponse.status).toBe(404);
    {{ '}' }});

    it('should return 403 when non-admin tries to delete', async () => {{ '{' }}
      const response = await request(app)
        .delete(`/api/users/${{ '{' }}testUserId{{ '}' }}`)
        .set('Authorization', `Bearer ${{ '{' }}userToken{{ '}' }}`);

      expect(response.status).toBe(403);
      expect(response.body.error.code).toBe('FORBIDDEN');
    {{ '}' }});
  {{ '}' }});
{{ '}' }});

E2E Tests: Complete User Flows

End-to-end tests simulate real user behavior, testing the entire application from browser to database.

Prompt for E2E Tests

Prompt - Generate E2E Tests (Playwright)
Generate E2E tests for user registration and login flow:

FLOW TO TEST:
1. User visits homepage
2. Clicks "Sign Up" button
3. Fills registration form (name, email, password)
4. Submits form
5. Sees success message
6. Is redirected to dashboard
7. Can log out and log back in

FRAMEWORK: Playwright with TypeScript

TEST SCENARIOS:
1. Happy path: Complete registration and login
2. Validation: Form shows errors for invalid input
3. Duplicate email: Shows appropriate error
4. Login with wrong password: Shows error
5. Remember me: Session persists after browser close

INCLUDE:
- Page Object Model for reusable selectors
- Helper functions for common actions
- Screenshot on failure
- Retry logic for flaky network requests

E2E Example with Playwright

TypeScript - auth.e2e.spec.ts
import {{ '{' }} test, expect {{ '}' }} from '@playwright/test';
import {{ '{' }} LoginPage {{ '}' }} from './pages/login.page';
import {{ '{' }} RegisterPage {{ '}' }} from './pages/register.page';
import {{ '{' }} DashboardPage {{ '}' }} from './pages/dashboard.page';
import {{ '{' }} generateTestEmail {{ '}' }} from './helpers';

test.describe('User Authentication Flow', () => {{ '{' }}
  test.describe('Registration', () => {{ '{' }}
    test('should complete registration successfully', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
      const registerPage = new RegisterPage(page);
      const dashboardPage = new DashboardPage(page);
      const testEmail = generateTestEmail();

      // Navigate to registration
      await registerPage.goto();
      await expect(page).toHaveTitle(/Sign Up/);

      // Fill form
      await registerPage.fillName('Test User');
      await registerPage.fillEmail(testEmail);
      await registerPage.fillPassword('SecurePass123!');
      await registerPage.fillConfirmPassword('SecurePass123!');

      // Submit and verify
      await registerPage.submit();

      // Should redirect to dashboard
      await expect(page).toHaveURL(/\/dashboard/);
      await expect(dashboardPage.welcomeMessage).toContainText('Welcome, Test User');
    {{ '}' }});

    test('should show validation errors for invalid input', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
      const registerPage = new RegisterPage(page);

      await registerPage.goto();

      // Submit empty form
      await registerPage.submit();

      // Check validation messages
      await expect(registerPage.nameError).toBeVisible();
      await expect(registerPage.emailError).toBeVisible();
      await expect(registerPage.passwordError).toBeVisible();
    {{ '}' }});

    test('should show error for duplicate email', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
      const registerPage = new RegisterPage(page);

      await registerPage.goto();
      await registerPage.fillName('Duplicate User');
      await registerPage.fillEmail('existing@test.com'); // Pre-seeded
      await registerPage.fillPassword('SecurePass123!');
      await registerPage.fillConfirmPassword('SecurePass123!');

      await registerPage.submit();

      await expect(registerPage.formError).toContainText('Email already registered');
    {{ '}' }});
  {{ '}' }});

  test.describe('Login', () => {{ '{' }}
    test('should login with valid credentials', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
      const loginPage = new LoginPage(page);
      const dashboardPage = new DashboardPage(page);

      await loginPage.goto();
      await loginPage.fillEmail('test@example.com');
      await loginPage.fillPassword('TestPass123!');
      await loginPage.submit();

      await expect(page).toHaveURL(/\/dashboard/);
      await expect(dashboardPage.userMenu).toBeVisible();
    {{ '}' }});

    test('should show error for invalid credentials', async ({{ '{' }} page {{ '}' }}) => {{ '{' }}
      const loginPage = new LoginPage(page);

      await loginPage.goto();
      await loginPage.fillEmail('test@example.com');
      await loginPage.fillPassword('WrongPassword');
      await loginPage.submit();

      await expect(loginPage.errorMessage).toContainText('Invalid credentials');
      await expect(page).toHaveURL(/\/login/); // Still on login page
    {{ '}' }});
  {{ '}' }});

  test.describe('Session Management', () => {{ '{' }}
    test('should persist session with remember me', async ({{ '{' }} page, context {{ '}' }}) => {{ '{' }}
      const loginPage = new LoginPage(page);

      await loginPage.goto();
      await loginPage.fillEmail('test@example.com');
      await loginPage.fillPassword('TestPass123!');
      await loginPage.checkRememberMe();
      await loginPage.submit();

      // Close and reopen browser
      await context.clearCookies();
      const cookies = await context.cookies();
      await context.addCookies(cookies);

      // Navigate to protected page
      await page.goto('/dashboard');

      // Should still be logged in
      await expect(page).toHaveURL(/\/dashboard/);
    {{ '}' }});
  {{ '}' }});
{{ '}' }});

TDD Workflow with Copilot

Test-Driven Development (TDD) works even better with Copilot. Write the test first, then let Copilot suggest the implementation.

TDD with Copilot

StepYouCopilot
1. RedWrite failing testSuggests test cases
2. GreenAsk for implementationGenerates minimal code
3. RefactorAsk for improvementsProposes refactoring
4. RepeatAdd new testSuggests edge cases
Prompt - TDD Workflow
I'm following TDD. Here's my failing test:

```typescript
describe('calculateDiscount', () => {{ '{' }}
  it('should apply 10% discount for orders over $100', () => {{ '{' }}
    expect(calculateDiscount(150)).toBe(15);
  {{ '}' }});

  it('should apply 20% discount for orders over $500', () => {{ '{' }}
    expect(calculateDiscount(600)).toBe(120);
  {{ '}' }});

  it('should return 0 for orders under $100', () => {{ '{' }}
    expect(calculateDiscount(50)).toBe(0);
  {{ '}' }});
{{ '}' }});
```

1. Implement the MINIMUM code to make these tests pass
2. Don't add features not covered by tests
3. After implementation, suggest additional edge cases I should test

Test Coverage and Gap Analysis

Use Copilot to identify uncovered code and generate missing tests.

Prompt - Coverage Gap Analysis
My coverage report shows uncovered lines:

FILE: src/services/order.service.ts
UNCOVERED LINES: 45-52, 78-85, 120-135

Here's the code at those lines:

```typescript
// Lines 45-52
if (order.status === 'cancelled') {{ '{' }}
  throw new ValidationError('Cannot modify cancelled order');
{{ '}' }}

// Lines 78-85
if (items.some(item => item.quantity > item.stock)) {{ '{' }}
  const outOfStock = items.filter(i => i.quantity > i.stock);
  throw new ValidationError(`Insufficient stock for: ${{ '{' }}outOfStock.map(i => i.name).join(', '){{ '}' }}`);
{{ '}' }}

// Lines 120-135
if (coupon && coupon.expiresAt < new Date()) {{ '{' }}
  throw new ValidationError('Coupon has expired');
{{ '}' }}
if (coupon && coupon.usageCount >= coupon.maxUsage) {{ '{' }}
  throw new ValidationError('Coupon usage limit reached');
{{ '}' }}
```

Generate unit tests to cover these scenarios. Include:
1. Test for each branch
2. Boundary conditions
3. Descriptive test names

Test Fixtures and Factories

Well-structured fixtures make tests more readable and maintainable.

TypeScript - Test Fixtures Factory
// fixtures/user.fixtures.ts

import {{ '{' }} User, UserRole, UserStatus {{ '}' }} from '@/entities/user.entity';
import {{ '{' }} CreateUserDto {{ '}' }} from '@/dto/create-user.dto';

// ===============================================================
// BASE FIXTURES - Valid default values
// ===============================================================

export const validUserDto: CreateUserDto = {{ '{' }}
  name: 'Test User',
  email: 'test@example.com',
  password: 'SecurePassword123!',
{{ '}' }};

export const validUser: Partial<User> = {{ '{' }}
  id: 'user-123',
  name: 'Test User',
  email: 'test@example.com',
  role: UserRole.USER,
  status: UserStatus.ACTIVE,
  createdAt: new Date('2025-01-01'),
  updatedAt: new Date('2025-01-01'),
{{ '}' }};

// ===============================================================
// FACTORY FUNCTION - Create users with overrides
// ===============================================================

let userCounter = 0;

export function createUserFixture(overrides: Partial<User> = {{ '{' }}{{ '}' }}): User {{ '{' }}
  userCounter++;
  return {{ '{' }}
    id: `user-${{ '{' }}userCounter{{ '}' }}`,
    name: 'Test User',
    email: `test-${{ '{' }}userCounter{{ '}' }}@example.com`,
    password: '$2b$10$hashedpassword', // Pre-hashed for tests
    role: UserRole.USER,
    status: UserStatus.ACTIVE,
    createdAt: new Date(),
    updatedAt: new Date(),
    deletedAt: null,
    ...overrides,
  {{ '}' }} as User;
{{ '}' }}

// ===============================================================
// INVALID FIXTURES - For testing validation
// ===============================================================

export const invalidUserDtos = {{ '{' }}
  missingName: {{ '{' }}
    email: 'test@example.com',
    password: 'SecurePassword123!',
  {{ '}' }},
  missingEmail: {{ '{' }}
    name: 'Test User',
    password: 'SecurePassword123!',
  {{ '}' }},
  invalidEmail: {{ '{' }}
    name: 'Test User',
    email: 'not-an-email',
    password: 'SecurePassword123!',
  {{ '}' }},
  weakPassword: {{ '{' }}
    name: 'Test User',
    email: 'test@example.com',
    password: '123',
  {{ '}' }},
  emptyName: {{ '{' }}
    name: '',
    email: 'test@example.com',
    password: 'SecurePassword123!',
  {{ '}' }},
{{ '}' }};

// ===============================================================
// SCENARIO FIXTURES - Specific test scenarios
// ===============================================================

export const userScenarios = {{ '{' }}
  admin: () => createUserFixture({{ '{' }} role: UserRole.ADMIN {{ '}' }}),
  inactive: () => createUserFixture({{ '{' }} status: UserStatus.INACTIVE {{ '}' }}),
  deleted: () => createUserFixture({{ '{' }}
    status: UserStatus.DELETED,
    deletedAt: new Date(),
  {{ '}' }}),
  premium: () => createUserFixture({{ '{' }}
    role: UserRole.PREMIUM,
    premiumExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  {{ '}' }}),
{{ '}' }};

// ===============================================================
// HELPERS - Reset counter for test isolation
// ===============================================================

export function resetUserFixtures(): void {{ '{' }}
  userCounter = 0;
{{ '}' }}

Guided Refactoring with Tests

Existing tests allow you to refactor safely. Copilot can suggest improvements while maintaining behavior.

Prompt - Refactoring with Safety Net
I want to refactor this code. I have tests that cover its behavior:

CURRENT CODE:
```typescript
[PASTE CODE]
```

EXISTING TESTS (passing):
```typescript
[PASTE TESTS]
```

REFACTORING GOALS:
1. Reduce cyclomatic complexity
2. Extract reusable functions
3. Improve naming

CONSTRAINTS:
- All existing tests MUST still pass
- Don't change public interface
- No new dependencies

Provide:
1. Refactored code
2. Explanation of each change
3. Any new tests needed for extracted functions

Assisted Debugging with Tests

Prompt - Debug Failing Test
This test is failing and I can't figure out why:

TEST:
```typescript
it('should apply discount to order total', async () => {{ '{' }}
  const order = await orderService.createOrder({{ '{' }}
    items: [{{ '{' }} productId: 'prod-1', quantity: 2 {{ '}' }}],
    couponCode: 'SAVE10',
  {{ '}' }});

  expect(order.total).toBe(90); // Expected $90 after 10% discount
  expect(order.discount).toBe(10);
{{ '}' }});
```

ERROR:
```
Expected: 90
Received: 100
```

IMPLEMENTATION:
```typescript
async createOrder(dto: CreateOrderDto): Promise<Order> {{ '{' }}
  const items = await this.getOrderItems(dto.items);
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  let discount = 0;
  if (dto.couponCode) {{ '{' }}
    const coupon = await this.couponRepository.findByCode(dto.couponCode);
    if (coupon) {{ '{' }}
      discount = subtotal * (coupon.percentage / 100);
    {{ '}' }}
  {{ '}' }}

  return this.orderRepository.create({{ '{' }}
    items,
    subtotal,
    discount,
    total: subtotal - discount,
  {{ '}' }});
{{ '}' }}
```

Help me:
1. Identify why the test is failing
2. Determine if it's a test bug or implementation bug
3. Provide the fix

Testing Best Practices

Anti-Patterns

  • Tests that depend on order
  • Shared state between tests
  • Vague names: "should work"
  • Multiple unrelated assertions
  • Too granular tests
  • Excessive mocking (testing mocks)
  • Slow tests without reason
  • Ignoring flaky tests

Best Practices

  • Independent and isolated tests
  • Fresh setup for each test
  • Descriptive names: "should return 404 when user not found"
  • One logical assertion per test
  • Test behavior, not implementation
  • Only mock external dependencies
  • Optimize shared setup
  • Fix or remove flaky tests

Test Quality Checklist

Before Merging

  • All tests pass locally
  • Coverage >= 80% for critical code
  • No flaky tests (run 3 times)
  • Test names are descriptive
  • Edge cases are covered
  • Error paths are tested
  • No console.log in tests
  • Fixtures are clean and reusable
  • Mocks are realistic
  • Tests are fast (unit < 10s total)

Conclusion and Next Steps

Testing is where Copilot truly shines. It can generate complete tests from existing code, suggest edge cases you might not have considered, and help you maintain high coverage without sacrificing development speed.

Remember: tests generated by Copilot must always be verified. AI can generate tests that pass but don't actually test the important behavior, or that are too tied to implementation rather than behavior.

In the next article we'll see how to use Copilot for professional documentation: complete READMEs, API documentation with OpenAPI, Architecture Decision Records, and JSDoc.

Key Points to Remember

  • Pyramid: 70% unit, 20% integration, 10% E2E
  • Unit: Complete isolation, mock dependencies
  • Integration: Test the API end-to-end with real DB
  • E2E: Simulate real user behavior
  • TDD: Write tests first, implement after
  • Coverage: Aim for 80%+ on critical code
  • Fixtures: Factory functions for reusable data
  • Verification: Always check generated tests

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects