Skip to content

Session 13 Testing

Maximiliano Militzer edited this page Apr 7, 2025 · 1 revision

Session 13: Testing Your App

Overview

Welcome to Session 13! In this session, we'll learn about testing your applications to ensure they work correctly and reliably. We'll cover various testing approaches and tools for both frontend and backend code, focusing on practices that help you build robust applications.

Learning Objectives

  • Understand the importance of testing in software development
  • Learn different types of tests (unit, integration, end-to-end)
  • Write effective tests using popular testing frameworks
  • Implement testing strategies for both frontend and backend code
  • Understand test-driven development (TDD)
  • Set up continuous integration for automated testing

Topics Covered

1. Why Testing Matters

Testing is a critical part of the software development process for several reasons:

  • Quality Assurance: Ensures your code works as expected
  • Bug Prevention: Catches bugs before they reach production
  • Regression Prevention: Makes sure new changes don't break existing functionality
  • Documentation: Tests serve as living documentation for how code should behave
  • Refactoring Confidence: Allows you to make changes with confidence
  • Code Design: Writing testable code often leads to better architecture

2. Types of Testing

Unit Testing

Testing individual functions, methods, or components in isolation.

  • Focus: Small, isolated pieces of code
  • Dependencies: Usually mocked or stubbed
  • Speed: Very fast
  • Example tools: Jest, Vitest, pytest

Integration Testing

Testing how multiple units or modules work together.

  • Focus: Interactions between components
  • Dependencies: Some real, some mocked
  • Speed: Moderately fast
  • Example tools: Jest, React Testing Library, SuperTest

End-to-End (E2E) Testing

Testing the complete application flow from start to finish.

  • Focus: User workflows and real-world scenarios
  • Dependencies: Real (or realistic staging environments)
  • Speed: Slower
  • Example tools: Cypress, Playwright, Selenium

Other Types of Tests

  • Snapshot Testing: Captures UI component output and compares against saved version
  • Visual Regression Testing: Compares screenshots to detect visual changes
  • Performance Testing: Tests application performance and load handling
  • Accessibility Testing: Tests for compliance with accessibility standards

3. Frontend Testing with React and Next.js

Setting Up Jest with Next.js

# Install dependencies
npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom

Create a Jest configuration file:

// jest.config.js
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'],
  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)

Set up the Jest environment:

// jest.setup.js
import '@testing-library/jest-dom'

Update your package.json:

"scripts": {
  "test": "jest",
  "test:watch": "jest --watch"
}

Component Testing with React Testing Library

React Testing Library is designed to test components in a way that resembles how users interact with your app.

Let's test a simple component:

// src/components/TaskItem.jsx
import React from 'react';

export function TaskItem({ task, onToggle, onDelete }) {
  return (
    <div className="task-item">
      <input 
        type="checkbox" 
        checked={task.completed} 
        onChange={() => onToggle(task.id)} 
        id={`task-${task.id}`}
      />
      <label 
        htmlFor={`task-${task.id}`}
        style={{ textDecoration: task.completed ? 'line-through' : 'none' }}
      >
        {task.title}
      </label>
      <button 
        onClick={() => onDelete(task.id)}
        aria-label={`Delete task ${task.title}`}
      >
        Delete
      </button>
    </div>
  );
}

Now, let's write a test for this component:

// src/components/__tests__/TaskItem.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { TaskItem } from '../TaskItem';

describe('TaskItem', () => {
  const mockTask = {
    id: 1,
    title: 'Test Task',
    completed: false
  };
  
  const mockToggle = jest.fn();
  const mockDelete = jest.fn();
  
  beforeEach(() => {
    mockToggle.mockClear();
    mockDelete.mockClear();
  });
  
  test('renders task correctly', () => {
    render(
      <TaskItem 
        task={mockTask} 
        onToggle={mockToggle} 
        onDelete={mockDelete} 
      />
    );
    
    expect(screen.getByText('Test Task')).toBeInTheDocument();
    expect(screen.getByRole('checkbox')).not.toBeChecked();
  });
  
  test('calls onToggle when checkbox is clicked', () => {
    render(
      <TaskItem 
        task={mockTask} 
        onToggle={mockToggle} 
        onDelete={mockDelete} 
      />
    );
    
    fireEvent.click(screen.getByRole('checkbox'));
    expect(mockToggle).toHaveBeenCalledWith(1);
  });
  
  test('calls onDelete when delete button is clicked', () => {
    render(
      <TaskItem 
        task={mockTask} 
        onToggle={mockToggle} 
        onDelete={mockDelete} 
      />
    );
    
    fireEvent.click(screen.getByRole('button', { name: /delete task test task/i }));
    expect(mockDelete).toHaveBeenCalledWith(1);
  });
  
  test('applies line-through style when task is completed', () => {
    const completedTask = { ...mockTask, completed: true };
    
    render(
      <TaskItem 
        task={completedTask} 
        onToggle={mockToggle} 
        onDelete={mockDelete} 
      />
    );
    
    expect(screen.getByText('Test Task')).toHaveStyle('text-decoration: line-through');
  });
});

Testing Hooks

Let's test a custom hook:

// src/hooks/useCounter.js
import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  const reset = () => setCount(initialValue);
  
  return { count, increment, decrement, reset };
}

Test for the hook:

// src/hooks/__tests__/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../useCounter';

describe('useCounter', () => {
  test('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
  
  test('should initialize with provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  test('should increment the counter', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('should decrement the counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
  
  test('should reset the counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(5);
  });
});

4. Backend Testing with Django

Setting Up pytest for Django

# Install dependencies
pip install pytest pytest-django

Create a pytest configuration file:

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = your_project.settings
python_files = test_*.py *_test.py

Writing API Tests

# tests/test_tasks_api.py
import pytest
from django.urls import reverse
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from tasks.models import Task, Category

@pytest.fixture
def api_client():
    return APIClient()

@pytest.fixture
def user():
    return User.objects.create_user(
        username='testuser',
        email='test@example.com',
        password='testpass123'
    )

@pytest.fixture
def authenticated_client(api_client, user):
    api_client.force_authenticate(user=user)
    return api_client

@pytest.fixture
def category(user):
    return Category.objects.create(
        name='Test Category',
        color='#FF5733',
        user=user
    )

@pytest.fixture
def task(user, category):
    return Task.objects.create(
        title='Test Task',
        completed=False,
        user=user,
        category=category
    )

@pytest.mark.django_db
def test_get_tasks(authenticated_client, task):
    url = reverse('tasks-list')
    response = authenticated_client.get(url)
    
    assert response.status_code == 200
    assert len(response.data) == 1
    assert response.data[0]['title'] == 'Test Task'

@pytest.mark.django_db
def test_create_task(authenticated_client, category, user):
    url = reverse('tasks-list')
    data = {
        'title': 'New Task',
        'completed': False,
        'category_id': category.id
    }
    
    response = authenticated_client.post(url, data)
    
    assert response.status_code == 201
    assert response.data['title'] == 'New Task'
    assert Task.objects.count() == 1
    assert Task.objects.first().user == user

@pytest.mark.django_db
def test_update_task(authenticated_client, task):
    url = reverse('tasks-detail', args=[task.id])
    data = {
        'title': 'Updated Task',
        'completed': True,
        'category_id': task.category.id
    }
    
    response = authenticated_client.put(url, data)
    
    assert response.status_code == 200
    assert response.data['title'] == 'Updated Task'
    assert response.data['completed'] is True
    
    task.refresh_from_db()
    assert task.title == 'Updated Task'
    assert task.completed is True

@pytest.mark.django_db
def test_delete_task(authenticated_client, task):
    url = reverse('tasks-detail', args=[task.id])
    
    response = authenticated_client.delete(url)
    
    assert response.status_code == 204
    assert Task.objects.count() == 0

@pytest.mark.django_db
def test_unauthenticated_access(api_client):
    url = reverse('tasks-list')
    response = api_client.get(url)
    
    assert response.status_code == 401

Model Testing

# tests/test_models.py
import pytest
from django.contrib.auth.models import User
from tasks.models import Task, Category, Priority
from datetime import datetime, timezone

@pytest.mark.django_db
def test_category_creation():
    user = User.objects.create_user(username='testuser', password='12345')
    category = Category.objects.create(
        name='Work',
        color='#FF5733',
        user=user
    )
    
    assert category.name == 'Work'
    assert category.color == '#FF5733'
    assert category.user == user

@pytest.mark.django_db
def test_task_creation():
    user = User.objects.create_user(username='testuser', password='12345')
    category = Category.objects.create(name='Work', color='#FF5733', user=user)
    priority = Priority.objects.create(level='HIGH')
    
    task = Task.objects.create(
        title='Finish project',
        description='Complete by Friday',
        user=user,
        category=category,
        priority=priority,
        due_date=datetime(2023, 12, 31, 12, 0, tzinfo=timezone.utc)
    )
    
    assert task.title == 'Finish project'
    assert task.description == 'Complete by Friday'
    assert task.user == user
    assert task.category == category
    assert task.priority == priority
    assert task.completed is False
    assert task.due_date.day == 31
    assert task.due_date.month == 12
    assert task.due_date.year == 2023

5. End-to-End Testing with Playwright

Playwright is a powerful tool for end-to-end testing that supports multiple browsers.

Setting Up Playwright

# Install Playwright
npm install --save-dev @playwright/test

# Install browsers
npx playwright install

Create a Playwright configuration file:

// playwright.config.js
const { devices } = require('@playwright/test');

/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
  testDir: './e2e',
  timeout: 30 * 1000,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'mobile safari',
      use: { ...devices['iPhone 12'] },
    },
  ],
};

module.exports = config;

Update your package.json:

"scripts": {
  "e2e": "playwright test"
}

Writing E2E Tests with Playwright

// e2e/todo.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Todo App', () => {
  test.beforeEach(async ({ page }) => {
    // Log in before each test
    await page.goto('http://localhost:3000/login');
    await page.fill('input[name="username"]', 'testuser');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    
    // Verify that we're logged in and on the tasks page
    await expect(page).toHaveURL('http://localhost:3000/tasks');
  });
  
  test('should display the task list', async ({ page }) => {
    // Check that the UI loaded correctly
    await expect(page.locator('h1')).toContainText('Your Tasks');
    await expect(page.locator('form')).toBeVisible();
  });
  
  test('should create a new task', async ({ page }) => {
    const taskName = 'E2E Test Task';
    
    // Fill in the form and submit
    await page.fill('input[placeholder="Add a new task..."]', taskName);
    await page.click('button[type="submit"]');
    
    // Verify that the new task appears in the list
    await expect(page.locator(`text=${taskName}`)).toBeVisible();
  });
  
  test('should complete a task', async ({ page }) => {
    // Create a task first
    const taskName = 'Task to Complete';
    await page.fill('input[placeholder="Add a new task..."]', taskName);
    await page.click('button[type="submit"]');
    
    // Find the task and toggle its completion status
    const taskItem = page.locator(`text=${taskName}`).first();
    await expect(taskItem).toBeVisible();
    
    // Click the checkbox
    await page.click(`label:has-text("${taskName}") >> xpath=../input[type="checkbox"]`);
    
    // Verify that the task is marked as completed (has line-through style)
    await expect(page.locator(`label:has-text("${taskName}")`)).toHaveCSS('text-decoration', /line-through/);
  });
  
  test('should delete a task', async ({ page }) => {
    // Create a task first
    const taskName = 'Task to Delete';
    await page.fill('input[placeholder="Add a new task..."]', taskName);
    await page.click('button[type="submit"]');
    
    // Find the task and click the delete button
    const taskItem = page.locator(`text=${taskName}`).first();
    await expect(taskItem).toBeVisible();
    
    await page.click(`label:has-text("${taskName}") >> xpath=../..//button[contains(@aria-label, "Delete")]`);
    
    // Verify that the task is removed
    await expect(page.locator(`text=${taskName}`)).not.toBeVisible();
  });
  
  test('should filter tasks', async ({ page }) => {
    // Create completed and active tasks
    await page.fill('input[placeholder="Add a new task..."]', 'Active Task');
    await page.click('button[type="submit"]');
    
    await page.fill('input[placeholder="Add a new task..."]', 'Completed Task');
    await page.click('button[type="submit"]');
    
    // Complete one task
    await page.click('label:has-text("Completed Task") >> xpath=../input[type="checkbox"]');
    
    // Filter by active tasks
    await page.click('button:has-text("Active")');
    await expect(page.locator('label:has-text("Active Task")')).toBeVisible();
    await expect(page.locator('label:has-text("Completed Task")')).not.toBeVisible();
    
    // Filter by completed tasks
    await page.click('button:has-text("Completed")');
    await expect(page.locator('label:has-text("Active Task")')).not.toBeVisible();
    await expect(page.locator('label:has-text("Completed Task")')).toBeVisible();
    
    // Show all tasks
    await page.click('button:has-text("All")');
    await expect(page.locator('label:has-text("Active Task")')).toBeVisible();
    await expect(page.locator('label:has-text("Completed Task")')).toBeVisible();
  });
});

6. Test-Driven Development (TDD)

Test-Driven Development is a software development process that relies on a very short development cycle:

  1. Write a failing test for a new feature
  2. Write the minimal amount of code to make the test pass
  3. Refactor the code while keeping the tests passing

Benefits of TDD:

  • Better understanding of requirements before coding
  • Immediate feedback on code changes
  • Built-in regression testing
  • Naturally modular and testable code
  • Documentation of how code should work

Example of TDD Workflow:

Let's say we want to add a feature to filter tasks by priority.

Step 1: Write a failing test

// src/components/__tests__/TaskList.test.jsx
test('should filter tasks by priority', () => {
  const tasks = [
    { id: 1, title: 'Task 1', completed: false, priority: { id: 1, level: 'LOW' } },
    { id: 2, title: 'Task 2', completed: false, priority: { id: 2, level: 'MEDIUM' } },
    { id: 3, title: 'Task 3', completed: false, priority: { id: 3, level: 'HIGH' } }
  ];
  
  render(<TaskList tasks={tasks} />);
  
  // All tasks should be visible initially
  expect(screen.getByText('Task 1')).toBeInTheDocument();
  expect(screen.getByText('Task 2')).toBeInTheDocument();
  expect(screen.getByText('Task 3')).toBeInTheDocument();
  
  // Open priority filter dropdown
  fireEvent.click(screen.getByText('Priority'));
  
  // Select "HIGH" priority
  fireEvent.click(screen.getByText('High'));
  
  // Only Task 3 should be visible
  expect(screen.queryByText('Task 1')).not.toBeInTheDocument();
  expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
  expect(screen.getByText('Task 3')).toBeInTheDocument();
});

Step 2: Implement the feature

// src/components/TaskList.jsx
function TaskList({ tasks }) {
  const [priorityFilter, setPriorityFilter] = useState(null);
  
  const filteredTasks = tasks.filter(task => {
    if (priorityFilter && task.priority.level !== priorityFilter) {
      return false;
    }
    return true;
  });
  
  return (
    <div>
      <div className="filters">
        <div className="dropdown">
          <button>Priority</button>
          <div className="dropdown-content">
            <button onClick={() => setPriorityFilter(null)}>All</button>
            <button onClick={() => setPriorityFilter('LOW')}>Low</button>
            <button onClick={() => setPriorityFilter('MEDIUM')}>Medium</button>
            <button onClick={() => setPriorityFilter('HIGH')}>High</button>
          </div>
        </div>
      </div>
      
      <ul className="task-list">
        {filteredTasks.map(task => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
    </div>
  );
}

Step 3: Refactor

Now we can refactor this code to make it more maintainable while keeping the tests passing.

7. Continuous Integration (CI)

Continuous Integration is the practice of automatically building and testing code changes when they're pushed to a repository.

Setting Up GitHub Actions for CI

Create a GitHub Actions workflow file:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test-frontend:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./frontend
    
    strategy:
      matrix:
        node-version: [16.x]
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v2
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
        cache-dependency-path: frontend/package-lock.json
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run linter
      run: npm run lint
    
    - name: Run tests
      run: npm test
  
  test-backend:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./backend
    
    services:
      postgres:
        image: postgres:13
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
    
    strategy:
      matrix:
        python-version: [3.9]
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v2
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
    
    - name: Run tests
      env:
        DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
      run: pytest
  
  e2e-tests:
    needs: [test-frontend, test-backend]
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Use Node.js
      uses: actions/setup-node@v2
      with:
        node-version: '16.x'
    
    - name: Install Playwright browsers
      run: npx playwright install --with-deps
    
    - name: Run Playwright tests
      run: npx playwright test

Practice Exercises

  1. Add unit tests for your Todo application components
  2. Write integration tests for your API endpoints
  3. Create end-to-end tests for critical user flows
  4. Implement a CI pipeline using GitHub Actions
  5. Practice test-driven development by adding a new feature
  6. Add code coverage reporting to your test setup
  7. Write tests for edge cases and error handling

Additional Resources

Next Steps

Now that you've learned how to test your applications, let's move on to Session 14: Next Steps where we'll explore advanced topics and discuss how to continue your learning journey.

Clone this wiki locally