-
Notifications
You must be signed in to change notification settings - Fork 0
Session 13 Testing
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.
- 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
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
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
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
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
- 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
# Install dependencies
npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdomCreate 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"
}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');
});
});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);
});
});# Install dependencies
pip install pytest pytest-djangoCreate a pytest configuration file:
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = your_project.settings
python_files = test_*.py *_test.py# 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# 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 == 2023Playwright is a powerful tool for end-to-end testing that supports multiple browsers.
# Install Playwright
npm install --save-dev @playwright/test
# Install browsers
npx playwright installCreate 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"
}// 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();
});
});Test-Driven Development is a software development process that relies on a very short development cycle:
- Write a failing test for a new feature
- Write the minimal amount of code to make the test pass
- Refactor the code while keeping the tests passing
- 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
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.
Continuous Integration is the practice of automatically building and testing code changes when they're pushed to a repository.
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- Add unit tests for your Todo application components
- Write integration tests for your API endpoints
- Create end-to-end tests for critical user flows
- Implement a CI pipeline using GitHub Actions
- Practice test-driven development by adding a new feature
- Add code coverage reporting to your test setup
- Write tests for edge cases and error handling
- Jest Documentation
- React Testing Library
- Playwright Documentation
- pytest Documentation
- Django Testing
- Test-Driven Development By Example
- GitHub Actions Documentation
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.