← Back to DevBytes

Testing Strategies for TypeScript Applications

Introduction to Testing TypeScript Applications

Testing is a critical part of the software development lifecycle, and when you're working with TypeScript, you have a unique advantage: static typing. However, TypeScript's type system alone cannot guarantee runtime correctness. A robust testing strategy ensures your application behaves as expected, catches regressions early, and gives you confidence when refactoring. In this tutorial, we'll explore a comprehensive testing strategy for TypeScript applications, covering unit tests, integration tests, end-to-end tests, and best practices for leveraging TypeScript's strengths throughout.

Why Testing Matters in TypeScript

TypeScript provides compile-time type safety, which eliminates a whole category of bugs related to incorrect types, missing properties, and undefined values. But types don't verify business logic. A function might accept the correct types and still return the wrong result. Testing fills this gap by verifying actual runtime behavior.

A well-structured testing strategy provides several key benefits:

The Testing Pyramid

Before diving into implementation, it's important to understand the testing pyramid, a conceptual framework for balancing different types of tests. The pyramid has three layers:

The pyramid shape reflects the ideal distribution: most of your tests should be unit tests because they're fast and reliable, while end-to-end tests should be fewer because they're slower and more brittle. Let's explore each layer in detail.

Setting Up Your Testing Environment

For TypeScript applications, Jest and Vitest are the two most popular testing frameworks. Vitest is particularly well-suited for projects using Vite, offering faster execution and native TypeScript support. In this tutorial, we'll use Vitest, but the concepts apply equally to Jest.

First, install the necessary dependencies:

npm install -D vitest @vitest/coverage-v8 @types/node

Next, add a test script to your package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Create a vitest.config.ts file in your project root:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/', '**/*.config.ts'],
    },
  },
});

With this setup, you can write tests in TypeScript without any additional transpilation steps. Vitest handles TypeScript natively.

Unit Testing in TypeScript

Unit tests verify the smallest testable pieces of your application in isolation. In TypeScript, this means testing individual functions, classes, or modules while mocking their dependencies. The goal is to test one thing at a time, as fast as possible.

Writing Your First Unit Test

Let's start with a simple example. Suppose you have a utility function that calculates the total price of items in a shopping cart:

// src/utils/pricing.ts

export interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

export function calculateSubtotal(items: CartItem[]): number {
  return items.reduce((total, item) => total + item.price * item.quantity, 0);
}

export function calculateTax(subtotal: number, taxRate: number): number {
  if (taxRate < 0 || taxRate > 1) {
    throw new Error('Tax rate must be between 0 and 1');
  }
  return subtotal * taxRate;
}

export function calculateTotal(items: CartItem[], taxRate: number): number {
  const subtotal = calculateSubtotal(items);
  const tax = calculateTax(subtotal, taxRate);
  return subtotal + tax;
}

Now let's write unit tests for these functions:

// src/utils/pricing.test.ts

import { describe, it, expect } from 'vitest';
import { calculateSubtotal, calculateTax, calculateTotal, CartItem } from './pricing';

describe('calculateSubtotal', () => {
  it('should return 0 for an empty cart', () => {
    expect(calculateSubtotal([])).toBe(0);
  });

  it('should calculate the subtotal for multiple items', () => {
    const items: CartItem[] = [
      { name: 'Widget', price: 10, quantity: 2 },
      { name: 'Gadget', price: 25, quantity: 1 },
    ];
    expect(calculateSubtotal(items)).toBe(45);
  });

  it('should handle items with zero quantity', () => {
    const items: CartItem[] = [
      { name: 'Widget', price: 10, quantity: 0 },
    ];
    expect(calculateSubtotal(items)).toBe(0);
  });
});

describe('calculateTax', () => {
  it('should calculate tax correctly', () => {
    expect(calculateTax(100, 0.08)).toBe(8);
  });

  it('should throw an error for negative tax rates', () => {
    expect(() => calculateTax(100, -0.1)).toThrow('Tax rate must be between 0 and 1');
  });

  it('should throw an error for tax rates above 100%', () => {
    expect(() => calculateTax(100, 1.5)).toThrow('Tax rate must be between 0 and 1');
  });
});

describe('calculateTotal', () => {
  it('should calculate the total including tax', () => {
    const items: CartItem[] = [
      { name: 'Widget', price: 10, quantity: 2 },
    ];
    expect(calculateTotal(items, 0.1)).toBe(22);
  });
});

Notice how TypeScript interfaces like CartItem are used directly in the test file. This ensures your tests are type-checked, catching mismatches between your test data and your actual data structures.

Testing Classes with Dependencies

Real-world applications rarely consist of pure functions. Let's look at testing a class that has dependencies. Consider a user service that depends on a repository:

// src/services/UserService.ts

export interface User {
  id: string;
  name: string;
  email: string;
}

export interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<User>;
  delete(id: string): Promise<void>;
}

export class UserService {
  constructor(private readonly repository: UserRepository) {}

  async getUser(id: string): Promise<User> {
    const user = await this.repository.findById(id);
    if (!user) {
      throw new Error(`User with id ${id} not found`);
    }
    return user;
  }

  async createUser(name: string, email: string): Promise<User> {
    const user: User = {
      id: crypto.randomUUID(),
      name,
      email,
    };
    return this.repository.save(user);
  }

  async deleteUser(id: string): Promise<void> {
    const user = await this.getUser(id);
    await this.repository.delete(user.id);
  }
}

To test this service in isolation, we mock the repository. Vitest provides built-in mocking capabilities:

// src/services/UserService.test.ts

import { describe, it, expect, vi } from 'vitest';
import { UserService, User, UserRepository } from './UserService';

function createMockRepository(): UserRepository {
  return {
    findById: vi.fn(),
    save: vi.fn(),
    delete: vi.fn(),
  };
}

describe('UserService', () => {
  describe('getUser', () => {
    it('should return a user when found', async () => {
      const mockRepo = createMockRepository();
      const mockUser: User = {
        id: '123',
        name: 'Alice',
        email: 'alice@example.com',
      };
      mockRepo.findById.mockResolvedValue(mockUser);

      const service = new UserService(mockRepo);
      const result = await service.getUser('123');

      expect(result).toEqual(mockUser);
      expect(mockRepo.findById).toHaveBeenCalledWith('123');
    });

    it('should throw an error when user is not found', async () => {
      const mockRepo = createMockRepository();
      mockRepo.findById.mockResolvedValue(null);

      const service = new UserService(mockRepo);

      await expect(service.getUser('999')).rejects.toThrow(
        'User with id 999 not found'
      );
    });
  });

  describe('createUser', () => {
    it('should create and save a new user', async () => {
      const mockRepo = createMockRepository();
      mockRepo.save.mockImplementation(async (user) => user);

      const service = new UserService(mockRepo);
      const result = await service.createUser('Bob', 'bob@example.com');

      expect(result.name).toBe('Bob');
      expect(result.email).toBe('bob@example.com');
      expect(result.id).toBeDefined();
      expect(mockRepo.save).toHaveBeenCalledTimes(1);
    });
  });

  describe('deleteUser', () => {
    it('should delete an existing user', async () => {
      const mockRepo = createMockRepository();
      const mockUser: User = {
        id: '123',
        name: 'Alice',
        email: 'alice@example.com',
      };
      mockRepo.findById.mockResolvedValue(mockUser);
      mockRepo.delete.mockResolvedValue(undefined);

      const service = new UserService(mockRepo);
      await service.deleteUser('123');

      expect(mockRepo.delete).toHaveBeenCalledWith('123');
    });

    it('should throw when trying to delete a non-existent user', async () => {
      const mockRepo = createMockRepository();
      mockRepo.findById.mockResolvedValue(null);

      const service = new UserService(mockRepo);

      await expect(service.deleteUser('999')).rejects.toThrow();
      expect(mockRepo.delete).not.toHaveBeenCalled();
    });
  });
});

By using dependency injection and mocking, we keep the test focused entirely on the UserService logic without touching a real database.

Integration Testing

Integration tests verify that multiple components work together correctly. Unlike unit tests, they don't mock everything — instead, they test real interactions between modules, databases, or external services. These tests are slower than unit tests but provide higher confidence that the system works as a whole.

Testing a Database Integration

Let's say you have a repository implementation that uses a PostgreSQL database. For integration tests, you might use a real test database or an in-memory database. Here's an example using a test database with transactional rollback to keep tests isolated:

// src/repositories/PostgresUserRepository.ts

import { Pool } from 'pg';
import { User, UserRepository } from '../services/UserService';

export class PostgresUserRepository implements UserRepository {
  constructor(private readonly pool: Pool) {}

  async findById(id: string): Promise<User | null> {
    const result = await this.pool.query(
      'SELECT id, name, email FROM users WHERE id = $1',
      [id]
    );
    return result.rows[0] ?? null;
  }

  async save(user: User): Promise<User> {
    await this.pool.query(
      'INSERT INTO users (id, name, email) VALUES ($1, $2, $3)',
      [user.id, user.name, user.email]
    );
    return user;
  }

  async delete(id: string): Promise<void> {
    await this.pool.query('DELETE FROM users WHERE id = $1', [id]);
  }
}

Here's an integration test that uses a real database connection:

// src/repositories/PostgresUserRepository.test.ts

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { Pool } from 'pg';
import { PostgresUserRepository } from './PostgresUserRepository';

const TEST_DB_URL = process.env.TEST_DATABASE_URL ?? 'postgresql://localhost/test_db';

describe('PostgresUserRepository', () => {
  let pool: Pool;
  let repository: PostgresUserRepository;

  beforeAll(() => {
    pool = new Pool({ connectionString: TEST_DB_URL });
    repository = new PostgresUserRepository(pool);
  });

  afterAll(async () => {
    await pool.end();
  });

  beforeEach(async () => {
    await pool.query('DELETE FROM users');
  });

  it('should save and retrieve a user', async () => {
    const user = {
      id: 'test-123',
      name: 'Test User',
      email: 'test@example.com',
    };

    await repository.save(user);
    const retrieved = await repository.findById('test-123');

    expect(retrieved).toEqual(user);
  });

  it('should return null for non-existent user', async () => {
    const result = await repository.findById('nonexistent');
    expect(result).toBeNull();
  });

  it('should delete a user', async () => {
    const user = {
      id: 'test-456',
      name: 'Delete Me',
      email: 'delete@example.com',
    };

    await repository.save(user);
    await repository.delete('test-456');
    const result = await repository.findById('test-456');

    expect(result).toBeNull();
  });
});

Testing API Endpoints

Integration tests for API endpoints verify the full request-response cycle, including routing, middleware, controllers, and database interactions. Here's an example using a simple Express-like server:

// src/server.ts

import express from 'express';
import { UserService } from './services/UserService';
import { PostgresUserRepository } from './repositories/PostgresUserRepository';
import { Pool } from 'pg';

export function createApp(pool: Pool) {
  const app = express();
  app.use(express.json());

  const repository = new PostgresUserRepository(pool);
  const userService = new UserService(repository);

  app.get('/users/:id', async (req, res) => {
    try {
      const user = await userService.getUser(req.params.id);
      res.json(user);
    } catch (err) {
      res.status(404).json({ error: (err as Error).message });
    }
  });

  app.post('/users', async (req, res) => {
    try {
      const { name, email } = req.body;
      const user = await userService.createUser(name, email);
      res.status(201).json(user);
    } catch (err) {
      res.status(400).json({ error: (err as Error).message });
    }
  });

  return app;
}

And the corresponding integration test:

// src/server.test.ts

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { Pool } from 'pg';
import { createApp } from './server';

const TEST_DB_URL = process.env.TEST_DATABASE_URL ?? 'postgresql://localhost/test_db';

describe('User API', () => {
  let pool: Pool;
  let app: ReturnType<typeof createApp>;

  beforeAll(() => {
    pool = new Pool({ connectionString: TEST_DB_URL });
    app = createApp(pool);
  });

  afterAll(async () => {
    await pool.end();
  });

  beforeEach(async () => {
    await pool.query('DELETE FROM users');
  });

  it('should create a new user via POST', async () => {
    const response = await request(app)
      .post('/users')
      .send({ name: 'API User', email: 'api@example.com' });

    expect(response.status).toBe(201);
    expect(response.body.name).toBe('API User');
    expect(response.body.email).toBe('api@example.com');
    expect(response.body.id).toBeDefined();
  });

  it('should retrieve a user via GET', async () => {
    const createResponse = await request(app)
      .post('/users')
      .send({ name: 'Get User', email: 'get@example.com' });

    const userId = createResponse.body.id;
    const getResponse = await request(app).get(`/users/${userId}`);

    expect(getResponse.status).toBe(200);
    expect(getResponse.body.name).toBe('Get User');
  });

  it('should return 404 for non-existent user', async () => {
    const response = await request(app).get('/users/nonexistent');
    expect(response.status).toBe(404);
    expect(response.body.error).toContain('not found');
  });
});

End-to-End Testing

End-to-end (E2E) tests simulate real user interactions with your application from start to finish. They interact with the application exactly as a user would — clicking buttons, filling out forms, and navigating between pages. For web applications, Playwright is an excellent choice for E2E testing.

Install Playwright:

npm install -D @playwright/test
npx playwright install

Create a playwright.config.ts file:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Here's an example E2E test for a user registration flow:

// e2e/userRegistration.spec.ts

import { test, expect } from '@playwright/test';

test.describe('User Registration', () => {
  test('should register a new user successfully', async ({ page }) => {
    await page.goto('/register');

    await page.fill('[data-testid="name-input"]', 'E2E Tester');
    await page.fill('[data-testid="email-input"]', 'e2e@example.com');
    await page.fill('[data-testid="password-input"]', 'SecurePass123!');
    await page.click('[data-testid="submit-button"]');

    await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
    await expect(page.locator('[data-testid="welcome-message"]')).toContainText(
      'Welcome, E2E Tester'
    );
  });

  test('should show validation errors for invalid input', async ({ page }) => {
    await page.goto('/register');

    await page.fill('[data-testid="email-input"]', 'not-an-email');
    await page.click('[data-testid="submit-button"]');

    await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
    await expect(page.locator('[data-testid="email-error"]')).toContainText(
      'Please enter a valid email'
    );
  });
});

E2E tests are powerful but slow and sometimes flaky. Keep your E2E suite focused on critical user journeys rather than testing every edge case.

Testing Async and Time-Dependent Code

Many applications rely on timers, dates, or scheduled tasks. Testing these reliably requires controlling time. Vitest provides built-in fake timers for this purpose:

// src/utils/tokenManager.ts

export class TokenManager {
  private tokens: Map<string, number> = new Map();

  createToken(value: string, ttlMs: number): string {
    const id = crypto.randomUUID();
    const expiresAt = Date.now() + ttlMs;
    this.tokens.set(id, expiresAt);
    return id;
  }

  isValid(id: string): boolean {
    const expiresAt = this.tokens.get(id);
    if (!expiresAt) return false;
    return Date.now() < expiresAt;
  }

  cleanup(): void {
    const now = Date.now();
    for (const [id, expiresAt] of this.tokens) {
      if (now >= expiresAt) {
        this.tokens.delete(id);
      }
    }
  }
}

Testing with fake timers:

// src/utils/tokenManager.test.ts

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TokenManager } from './tokenManager';

describe('TokenManager', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('should create a valid token', () => {
    const manager = new TokenManager();
    const token = manager.createToken('data', 5000);

    expect(manager.isValid(token)).toBe(true);
  });

  it('should invalidate token after TTL expires', () => {
    const manager = new TokenManager();
    const token = manager.createToken('data', 5000);

    vi.advanceTimersByTime(5001);

    expect(manager.isValid(token)).toBe(false);
  });

  it('should clean up expired tokens', () => {
    const manager = new TokenManager();
    manager.createToken('data1', 1000);
    manager.createToken('data2', 5000);

    vi.advanceTimersByTime(2000);
    manager.cleanup();

    // Only the second token should remain valid
    expect(manager.isValid('data1')).toBe(false);
  });
});

Property-Based Testing

Property-based testing is a powerful technique where instead of writing specific test cases, you define properties that should always hold true, and the framework generates hundreds of random inputs to verify those properties. The fast-check library is the standard choice for TypeScript:

npm install -D fast-check

Here's an example testing a sorting function:

// src/utils/sort.ts

export function sortNumbers(arr: number[]): number[] {
  return [...arr].sort((a, b) => a - b);
}
// src/utils/sort.test.ts

import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
import { sortNumbers } from './sort';

describe('sortNumbers', () => {
  it('should return an array of the same length', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const result = sortNumbers(arr);
        return result.length === arr.length;
      })
    );
  });

  it('should produce a sorted array', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const result = sortNumbers(arr);
        for (let i = 1; i < result.length; i++) {
          if (result[i - 1] > result[i]) return false;
        }
        return true;
      })
    );
  });

  it('should contain the same elements as the input', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const result = sortNumbers(arr);
        const sortedInput = [...arr].sort((a, b) => a - b);
        return JSON.stringify(result) === JSON.stringify(sortedInput);
      })
    );
  });
});

Property-based testing excels at finding edge cases you might not think of, such as empty arrays, arrays with duplicate values, or arrays with negative numbers.

Best Practices for TypeScript Testing

1. Leverage TypeScript Types in Tests

One of the biggest advantages of testing in TypeScript is that your tests are type-checked. Always import and use your interfaces and types in test files. This catches breaking changes in your data structures at compile time, not just at test time.

// Good: typed test data
import { User } from './UserService';

const testUser: User = {
  id: '123',
  name: 'Alice',
  email: 'alice@example.com',
};

// If the User interface changes, TypeScript will flag this immediately

2. Use Test Factories for Complex Objects

When tests require complex objects, create factory functions that produce valid test data with sensible defaults. This reduces duplication and makes tests easier to maintain:

// src/test/factories.ts

import { User } from '../services/UserService';

export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: crypto.randomUUID(),
    name: 'Default User',
    email: 'default@example.com',
    ...overrides,
  };
}

// Usage in tests:
const adminUser = createUser({ name: 'Admin', email: 'admin@example.com' });
const guestUser = createUser({ name: 'Guest' });

3. Follow the Arrange-Act-Assert Pattern

Structure your tests with clear separation between setup, execution, and verification. This makes tests more readable and easier to debug:

it('should update user email', async () => {
  // Arrange
  const user = createUser({ email: 'old@example.com' });
  mockRepo.findById.mockResolvedValue(user);
  mockRepo.save.mockImplementation(async (u) => u);

  // Act
  const updated = await service.updateEmail(user.id, 'new@example.com');

  // Assert
  expect(updated.email).toBe('new@example.com');
  expect(mockRepo.save).toHaveBeenCalledWith(
    expect.objectContaining({ email: 'new@example.com' })
  );
});

4. Avoid Testing Implementation Details

Test behavior, not implementation. If your tests are tightly coupled to how code is implemented internally, they'll break every time you refactor — even if the behavior hasn't changed. Prefer testing public APIs and observable outcomes over internal method calls and state.

5. Keep Tests Independent

Each test should be able to run in isolation and in any order. Avoid shared mutable state between tests. Use beforeEach and afterEach hooks to reset state:

describe('OrderService', () => {
  let service: OrderService;

  beforeEach(() => {
    // Fresh instance for every test
    service = new OrderService(new InMemoryOrderRepository());
  });

  it('should create an order', async () => {
    // This test doesn't depend on any other test
  });
});

6. Set Meaningful Coverage Targets

Coverage is a useful metric, but 100% coverage doesn't guarantee good tests. Set reasonable targets (e.g., 80% for lines and branches) and focus on testing critical business logic. Use coverage reports to identify untested code paths, not as a goal in itself:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      thresholds: {
        lines: 80,
        branches: 75,
        functions: 80,
        statements: 80,
      },
    },
  },
});

7. Use Snapshot Testing Sparingly

Snapshot tests are useful for serializing complex outputs, but they can become a maintenance burden. Developers often update snapshots without carefully reviewing changes. Use snapshots for things like generated configuration or UI components, but prefer explicit assertions for business logic:

// Acceptable: snapshot for a generated report structure
it('should generate a report with correct structure', () => {
  const report = generateReport(testData);
  expect(report).toMatchSnapshot();
});

// Better: explicit assertions for business logic
it('should calculate report totals correctly', () => {
  const report = generateReport(testData);
  expect(report.totalRevenue).toBe(15000);
  expect(report.totalOrders).toBe(42);
});

8. Test Error Paths Explicitly

Don't just test the happy path. Explicitly test error conditions, edge cases, and boundary values. This is where bugs often hide:

describe('validateEmail', () => {
  it.each([
    ['user@example.com', true],
    ['user.name@example.com', true],
    ['user+tag@example.co.uk', true],
    ['', false],
    ['notanemail', false],
    ['@example.com', false],
    ['user@', false],
    ['user@example', false],
  ])('should return %s for %s', (input, expected) => {
    expect(validateEmail(input)).toBe(expected);
  });
});

Organizing Your Test Files

A clear file organization strategy helps keep your test suite maintainable as it grows. Two common approaches are:

Co-location is generally preferred for unit tests, while integration and E2E tests often live in separate directories due to their different setup requirements.

Continuous Integration Considerations

Your testing strategy should integrate seamlessly with CI/CD pipelines. Here are key considerations:

Here's a sample GitHub Actions workflow:

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:coverage
        env:
          TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
      - run: npx playwright test
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db

Conclusion

A comprehensive testing strategy is essential for building reliable TypeScript applications. By leveraging the testing pyramid — with a broad base of fast unit tests, a solid middle layer of integration tests, and a small set of end-to-end tests — you can achieve both speed and confidence. TypeScript's static typing enhances your testing efforts by catching type-related errors at compile time, while tools like Vitest, Playwright, and fast-check handle runtime verification. Remember that good tests focus on behavior rather than implementation details, stay independent and maintainable, and cover both happy paths and edge cases. By following the practices outlined in this tutorial and integrating them into your CI/CD pipeline, you'll build a safety net that allows your team to move fast without breaking things, ultimately delivering higher-quality software to your users.

— Ad —

Google AdSense will appear here after approval

← Back to all articles