Class 40: Testing Web Applications (Unit, Integration, E2E)
Throughout this course, you've learned to build full-stack web applications. A crucial, yet often overlooked, aspect of professional software development is testing. Testing ensures that your code works as expected, remains stable as you add new features, and helps catch bugs early in the development cycle.
Today, we'll explore different types of testing for web applications: Unit, Integration, and End-to-End (E2E) testing, along with common tools and concepts.
The Importance of Testing
-
Why test?
- Catch bugs early: The earlier a bug is found, the cheaper and easier it is to fix.
- Ensure quality: Verifies that the application meets requirements and functions correctly.
- Refactoring confidence: Allows you to restructure code without fear of breaking existing functionality.
- Better collaboration: Provides a shared understanding of expected behavior.
- Documentation: Tests can serve as living documentation of your code's behavior.
-
Types of bugs and where testing fits:
Testing helps address various types of bugs, from small logical errors in a single function to complex issues arising from interactions between different parts of the system.
-
Test pyramid:
The test pyramid is a concept that suggests you should have more low-level tests (unit tests) than high-level tests (E2E tests).
- End-to-End Tests (Top): Slow, complex, tests the entire user flow.
- Integration Tests (Middle): Moderate speed, tests interactions between units.
- Unit Tests (Bottom): Fast, isolated, covers small units of code.

Unit Testing
-
What is unit testing?
Testing the smallest testable parts of an application (a "unit") in isolation. A unit can be a function, a method, or a class.
-
Characteristics:
- Fast: Run quickly, allowing frequent execution.
- Isolated: Each test runs independently, without affecting others.
- Reliable: Should pass consistently if the unit works correctly.
-
Jest (for Backend Node.js Logic):
Jest is a popular JavaScript testing framework developed by Facebook, often used for both frontend and backend unit testing.
npm install --save-dev jest
Add a script to your
package.json
:"scripts": { "test": "jest" }
Example: Create
utils/math.js
// utils/math.js exports.add = (a, b) => a + b; exports.subtract = (a, b) => a - b; exports.multiply = (a, b) => a * b;
Create
__tests__/math.test.js
// __tests__/math.test.js const { add, subtract, multiply } = require('../utils/math'); describe('Math Utility Functions', () => { test('add function should correctly add two numbers', () => { expect(add(1, 2)).toBe(3); expect(add(-1, 1)).toBe(0); expect(add(0, 0)).toBe(0); }); test('subtract function should correctly subtract two numbers', () => { expect(subtract(5, 2)).toBe(3); expect(subtract(10, 20)).toBe(-10); }); test('multiply function should correctly multiply two numbers', () => { expect(multiply(2, 3)).toBe(6); expect(multiply(-5, 10)).toBe(-50); expect(multiply(7, 0)).toBe(0); }); });
Run tests:
npm test
Integration Testing
-
What is integration testing?
Testing how different units or modules work together as a group. The goal is to ensure that the interfaces between components are correct and that data flows properly.
-
Examples:
Testing an Express API endpoint that interacts with a database, or a service layer that calls multiple utility functions.
-
Testing Express API routes with
supertest
:supertest
is a library that allows you to test HTTP requests to your Express application without actually running the server on a port.npm install --save-dev supertest
Example (assuming your Express app is in
server.js
):// __tests__/api.test.js const request = require('supertest'); const app = require('../server'); // Assuming your Express app is exported from server.js describe('Books API', () => { // Before each test, you might want to clear/seed your test database // For in-memory data, you might reset the array // For real DB, use a test database or clear tables test('GET /api/books should return all books', async () => { const response = await request(app).get('/api/books'); expect(response.statusCode).toBe(200); expect(Array.isArray(response.body)).toBeTruthy(); expect(response.body.length).toBeGreaterThan(0); // Assuming some initial data }); test('GET /api/books/:id should return a single book', async () => { // Assuming book with ID '1' exists const response = await request(app).get('/api/books/1'); expect(response.statusCode).toBe(200); expect(response.body).toHaveProperty('title', 'The Hitchhiker\'s Guide to the Galaxy'); }); test('POST /api/books should create a new book', async () => { const newBookData = { title: 'New Test Book', author: 'Test Author', year: 2023 }; const response = await request(app) .post('/api/books') .send(newBookData) .set('Content-Type', 'application/json'); expect(response.statusCode).toBe(201); expect(response.body).toHaveProperty('id'); expect(response.body.title).toBe(newBookData.title); }); test('POST /api/books should return 400 for invalid data', async () => { const invalidBookData = { title: 'Missing Author' }; // Missing author and year const response = await request(app) .post('/api/books') .send(invalidBookData) .set('Content-Type', 'application/json'); expect(response.statusCode).toBe(400); expect(response.body).toHaveProperty('message', 'Validation failed'); }); // Add tests for PUT, PATCH, DELETE });
To make your Express app exportable for testing, modify
server.js
to export theapp
instance:// server.js (at the end) // ... module.exports = app; // Export the app for testing
Frontend Testing with React Testing Library
-
Why React Testing Library?
React Testing Library (RTL) focuses on testing user behavior rather than implementation details. It encourages you to write tests that simulate how users interact with your components, making your tests more robust to code changes and more aligned with actual user experience.
-
Installation:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest
Ensure Jest is configured for React (e.g., in
package.json
):"jest": { "testEnvironment": "jsdom", "setupFilesAfterEnv": ["
/src/setupTests.js"] } Create
src/setupTests.js
:// src/setupTests.js import '@testing-library/jest-dom';
-
Basic RTL Concepts:
-
render
: Renders a React component into a container and returns utilities for querying the DOM. -
Queries: Methods to find elements
in the rendered component. Prioritize queries that
mimic how users find elements (e.g.,
getByRole
,getByText
,getByLabelText
).-
getByRole
: Finds elements by their ARIA role (e.g.,button
,textbox
). Most semantic. -
getByText
: Finds elements by their text content. -
findBy*
: For asynchronous elements (returns a Promise). -
queryBy*
: Returnsnull
if element not found (useful for asserting absence).
-
-
fireEvent
: Simulates user interactions (e.g.,click
,change
,submit
). -
Assertions: Use Jest's
expect
combined with@testing-library/jest-dom
matchers (e.g.,toBeInTheDocument
,toHaveTextContent
).
-
-
Example: Testing a simple Counter component:
// src/components/Counter.jsx import React, { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return ( <div> <h1 data-testid="count-value">Count: {count}</h1> <button onClick={() => setCount(count + 1)}>Increment</button> <button onClick={() => setCount(count - 1)}>Decrement</button> </div> ); } export default Counter;
// src/__tests__/Counter.test.jsx import { render, screen, fireEvent } from '@testing-library/react'; import Counter from '../components/Counter'; describe('Counter Component', () => { test('renders with initial count of 0', () => { render(<Counter />); // Use getByRole for semantic elements or data-testid for fallback expect(screen.getByTestId('count-value')).toHaveTextContent('Count: 0'); }); test('increments count when Increment button is clicked', () => { render(<Counter />); const incrementButton = screen.getByRole('button', { name: /increment/i }); fireEvent.click(incrementButton); expect(screen.getByTestId('count-value')).toHaveTextContent('Count: 1'); }); test('decrements count when Decrement button is clicked', () => { render(<Counter />); const decrementButton = screen.getByRole('button', { name: /decrement/i }); fireEvent.click(decrementButton); expect(screen.getByTestId('count-value')).toHaveTextContent('Count: -1'); }); });
End-to-End (E2E) Testing
-
What is E2E testing?
Simulating a real user's journey through the entire application, from the frontend UI, through the backend API, to the database, and back. It verifies that all parts of the system work together seamlessly.
-
Testing front-to-back:
An E2E test might involve: navigating to a login page, entering credentials, clicking login, verifying redirection, navigating to a product page, adding an item to a cart, and checking out.
-
Cypress / Playwright (Introduction):
These are popular E2E testing frameworks that automate browser interactions.
- Cypress: A JavaScript-based E2E testing framework that runs directly in the browser. Known for its developer-friendly experience and debugging capabilities.
- Playwright: Developed by Microsoft, supports multiple browsers (Chromium, Firefox, WebKit) and languages (JavaScript, Python, Java, .NET). Excellent for cross-browser testing.
-
Writing simple E2E test scenarios:
Example (Cypress syntax conceptual):
// cypress/e2e/auth.cy.js describe('Authentication Flow', () => { it('should allow a user to log in and view profile', () => { cy.visit('/login'); // Visit the login page cy.get('input[name="email"]').type('test@example.com'); cy.get('input[name="password"]').type('password123'); cy.get('button[type="submit"]').click(); cy.url().should('include', '/profile'); // Assert redirection cy.contains('Welcome to your profile!').should('be.visible'); }); it('should show an error for invalid login credentials', () => { cy.visit('/login'); cy.get('input[name="email"]').type('wrong@example.com'); cy.get('input[name="password"]').type('wrongpassword'); cy.get('button[type="submit"]').click(); cy.contains('Invalid credentials.').should('be.visible'); }); });
- Benefits: High confidence that the entire system works from a user's perspective.
- Drawbacks: Slower to run, more brittle (small UI changes can break tests), harder to debug compared to unit tests.
Testing Concepts: Mocking, Stubbing, Spies, Test Doubles
When testing, especially unit and integration tests, you often need to control the behavior of external dependencies (e.g., database calls, API requests, third-party services) to ensure your tests are isolated and fast. These techniques are called "Test Doubles."
-
Mocking:
Creating fake versions of external dependencies that mimic the behavior of real objects. Mocks are used to verify interactions (e.g., "was this function called with these arguments?").
// Example with Jest mock function const mockDb = { saveUser: jest.fn(() => Promise.resolve({ id: 'new_id', name: 'Test' })) }; // In your test: // await userService.createUser(user, mockDb); // expect(mockDb.saveUser).toHaveBeenCalledWith(user);
-
Stubbing:
Providing predefined responses to function calls. Stubs are used to control the behavior of dependencies, so your test can focus on the unit under test.
// Example: Stubbing a network request // jest.mock('axios'); // axios.get.mockResolvedValue({ data: { id: 1, name: 'Product' } }); // const product = await fetchProduct(1); // expect(product.name).toBe('Product');
-
Spies:
Observing function calls without altering their behavior. Spies are useful for checking if a function was called, how many times, and with what arguments.
// Example with Jest spy const consoleSpy = jest.spyOn(console, 'log'); // myFunctionThatLogs(); // expect(consoleSpy).toHaveBeenCalledWith('Something was logged'); consoleSpy.mockRestore(); // Clean up the spy
- Test Doubles: A general term for any object that takes the place of a real object for testing purposes. Mocks, stubs, and spies are types of test doubles.
Test-Driven Development (TDD)
Test-Driven Development (TDD) is a software development process where tests are written *before* the code they are meant to test.
-
Red-Green-Refactor cycle:
- Red: Write a failing test for a new feature or bug fix.
- Green: Write just enough code to make the test pass.
- Refactor: Improve the code's design while ensuring all tests still pass.
- Benefits: Leads to cleaner code, fewer bugs, and a clearer understanding of requirements.
Congratulations! You've reached the end of the Full Stack Web Development curriculum. By understanding and applying these testing methodologies, you can build more reliable, maintainable, and high-quality web applications. Keep practicing, keep building, and keep learning!