# 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 ```bash # Install dependencies npm install --save-dev jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom ``` Create a Jest configuration file: ```javascript // 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: ['/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: ```javascript // jest.setup.js import '@testing-library/jest-dom' ``` Update your package.json: ```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: ```jsx // src/components/TaskItem.jsx import React from 'react'; export function TaskItem({ task, onToggle, onDelete }) { return (
onToggle(task.id)} id={`task-${task.id}`} />
); } ``` Now, let's write a test for this component: ```jsx // 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( ); expect(screen.getByText('Test Task')).toBeInTheDocument(); expect(screen.getByRole('checkbox')).not.toBeChecked(); }); test('calls onToggle when checkbox is clicked', () => { render( ); fireEvent.click(screen.getByRole('checkbox')); expect(mockToggle).toHaveBeenCalledWith(1); }); test('calls onDelete when delete button is clicked', () => { render( ); 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( ); expect(screen.getByText('Test Task')).toHaveStyle('text-decoration: line-through'); }); }); ``` #### Testing Hooks Let's test a custom hook: ```jsx // 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: ```jsx // 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 ```bash # Install dependencies pip install pytest pytest-django ``` Create a pytest configuration file: ```ini # pytest.ini [pytest] DJANGO_SETTINGS_MODULE = your_project.settings python_files = test_*.py *_test.py ``` #### Writing API Tests ```python # 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 ```python # 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 ```bash # Install Playwright npm install --save-dev @playwright/test # Install browsers npx playwright install ``` Create a Playwright configuration file: ```javascript // 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: ```json "scripts": { "e2e": "playwright test" } ``` #### Writing E2E Tests with Playwright ```javascript // 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** ```javascript // 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(); // 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** ```jsx // 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 (
    {filteredTasks.map(task => (
  • {task.title}
  • ))}
); } ``` **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: ```yaml # .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 - [Jest Documentation](https://jestjs.io/docs/getting-started) - [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) - [Playwright Documentation](https://playwright.dev/docs/intro) - [pytest Documentation](https://docs.pytest.org/) - [Django Testing](https://docs.djangoproject.com/en/stable/topics/testing/) - [Test-Driven Development By Example](https://www.amazon.com/Test-Driven-Development-Kent-Beck/dp/0321146530) - [GitHub Actions Documentation](https://docs.github.com/en/actions) ## Next Steps Now that you've learned how to test your applications, let's move on to [Session 14: Next Steps](Session-14-Next-Steps.md) where we'll explore advanced topics and discuss how to continue your learning journey.