Let’s dive into frontend testing, focusing on unit testing and end-to-end (E2E) testing. These are essential tools for ensuring code quality and preventing production bugs. Unit testing targets individual components or functions, while E2E testing simulates real user scenarios, covering the entire application. We’ll break down their principles, tools, and implementations with detailed React and Vue code examples, guiding you step-by-step from scratch. Our focus is on practical, technical details to help you write tests that are fast and reliable!
Why Frontend Testing Matters
Frontend code is increasingly complex, with components, state, and async requests galore. Manual testing is time-consuming and prone to missing bugs. Unit tests quickly validate individual module logic, while E2E tests ensure user interactions work as expected. Together, they cover everything from code details to user experience, reducing the risk of production failures. Let’s start with unit testing and then dive into E2E testing.
Unit Testing: Starting with the Smallest Modules
Unit testing focuses on the smallest code units, like a function, a React component, or a Vue computed property. It’s fast, isolated, and provides precise feedback, ideal for catching logical errors. Popular tools include Jest, with React Testing Library for React and Vue Test Utils for Vue.
Jest: The Swiss Army Knife of Testing Frameworks
Jest is a powerful, zero-config testing framework supporting assertions, mocks, and snapshot testing. Let’s write a simple function test with Jest.
Project Setup
Create a Node project:
mkdir unit-test-demo
cd unit-test-demo
npm init -y
npm install --save-dev jest
Configure package.json
:
{
"scripts": {
"test": "jest"
}
}
Write a utility function in src/utils.js
:
export function add(a, b) {
return a + b;
}
export function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
Writing Unit Tests
Create src/utils.test.js
:
const { add, formatCurrency } = require('./utils');
describe('utils', () => {
test('add sums two numbers correctly', () => {
expect(add(2, 3)).toBe(5);
expect(add(-1, 1)).toBe(0);
expect(add(0, 0)).toBe(0);
});
test('formatCurrency formats USD correctly', () => {
expect(formatCurrency(99.99)).toBe('$99.99');
expect(formatCurrency(0)).toBe('$0.00');
});
test('formatCurrency handles different currencies', () => {
expect(formatCurrency(99.99, 'EUR')).toBe('€99.99');
expect(formatCurrency(1000, 'JPY')).toBe('¥1,000');
});
});
Run npm test
. Jest executes the tests and outputs results. describe
groups test cases, test
defines individual tests, and expect
makes assertions. toBe
checks strict equality, suitable for primitives.
Mock Functions
Mocks are crucial for testing async logic or external dependencies. Update utils.js
with an async API call:
export async function fetchUser(id) {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
return response.json();
}
Test in utils.test.js
:
const { fetchUser } = require('./utils');
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: 1, name: 'Alice' })
})
);
test('fetchUser returns user data', async () => {
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: 'Alice' });
expect(fetch).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/1');
});
jest.fn
mocks fetch
, avoiding real network requests. toHaveBeenCalledWith
verifies the call arguments.
React Unit Testing: Testing Library
React component testing should mimic user interactions. React Testing Library (RTL) encourages testing user-visible behavior rather than implementation details.
Project Setup
Use Create React App:
npx create-react-app react-test-demo
cd react-test-demo
npm install --save-dev @testing-library/react @testing-library/jest-dom
Add jest-dom
to src/setupTests.js
:
import '@testing-library/jest-dom';
Testing a Simple Component
Create a counter component in src/Counter.js
:
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p data-testid="count">Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
export default Counter;
Test in src/Counter.test.js
:
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('renders initial count', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
});
test('increments count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');
});
test('decrements count', () => {
render(<Counter />);
fireEvent.click(screen.getByText('Decrement'));
expect(screen.getByTestId('count')).toHaveTextContent('Count: -1');
});
Run npm test
. Tests pass, with render
rendering to a virtual DOM, screen.getByTestId
finding elements, fireEvent.click
simulating clicks, and toHaveTextContent
verifying text.
Testing Async Components
Create a component src/UserList.js
to fetch users from an API:
import React, { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) return <p>Loading...</p>;
return (
<ul data-testid="user-list">
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default UserList;
Test in src/UserList.test.js
:
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
})
);
test('renders user list after loading', async () => {
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => expect(screen.getByTestId('user-list')).toBeInTheDocument());
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
waitFor
waits for async operations, and toBeInTheDocument
(from jest-dom
) checks element presence.
Vue Unit Testing: Vue Test Utils
Vue uses Vue Test Utils (VTU) with Jest for component testing, providing APIs to mount components, trigger events, and access Vue instances.
Project Setup
Use Vue CLI:
npm install -g @vue/cli
vue create vue-test-demo
cd vue-test-demo
npm install --save-dev @vue/test-utils@2 jest vue-jest@5
Configure package.json
:
{
"scripts": {
"test": "jest"
}
}
Create jest.config.js
:
module.exports = {
preset: '@vue/cli-plugin-unit-jest'
};
Testing a Simple Component
Create src/components/Counter.vue
:
<template>
<div>
<p data-testid="count">Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
</div>
</template>
<script>
export default {
data() {
return { count: 0 };
},
methods: {
increment() {
this.count++;
},
decrement() {
this.count--;
}
}
};
</script>
Test in tests/unit/Counter.spec.js
:
import { mount } from '@vue/test-utils';
import Counter from '@/components/Counter.vue';
describe('Counter', () => {
test('renders initial count', () => {
const wrapper = mount(Counter);
expect(wrapper.find('[data-testid="count"]').text()).toBe('Count: 0');
});
test('increments count', async () => {
const wrapper = mount(Counter);
await wrapper.find('button:nth-child(2)').trigger('click');
expect(wrapper.find('[data-testid="count"]').text()).toBe('Count: 1');
});
test('decrements count', async () => {
const wrapper = mount(Counter);
await wrapper.find('button:nth-child(3)').trigger('click');
expect(wrapper.find('[data-testid="count"]').text()).toBe('Count: -1');
});
});
Run npm test
. mount
renders the component, trigger
simulates clicks, and async/await
handles Vue state updates.
Testing Async Components
Create src/components/UserList.vue
:
<template>
<div>
<p v-if="loading">Loading...</p>
<ul data-testid="user-list" v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
users: [],
loading: true
};
},
async mounted() {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
this.users = await res.json();
this.loading = false;
}
};
</script>
Test in tests/unit/UserList.spec.js
:
import { mount } from '@vue/test-utils';
import UserList from '@/components/UserList.vue';
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
})
);
describe('UserList', () => {
test('renders user list after loading', async () => {
const wrapper = mount(UserList);
expect(wrapper.text()).toContain('Loading...');
await wrapper.vm.$nextTick(); // Wait for Vue update
await new Promise(resolve => setTimeout(resolve, 0)); // Wait for fetch
expect(wrapper.find('[data-testid="user-list"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Alice');
expect(wrapper.text()).toContain('Bob');
});
});
vm.$nextTick
waits for Vue rendering, and setTimeout
simulates async behavior.
End-to-End Testing: Simulating Real Users
E2E testing mimics user actions in a browser, like clicking, typing, and navigating. Cypress is a powerful E2E testing tool with cross-browser support.
Cypress Setup
In the react-test-demo
project:
npm install --save-dev cypress
Create cypress.config.js
:
module.exports = {
e2e: {
baseUrl: 'http://localhost:3000'
}
};
Add to package.json
:
{
"scripts": {
"cypress": "cypress open"
}
}
Testing a React Application
Write cypress/e2e/counter.cy.js
:
describe('Counter', () => {
it('increments and decrements count', () => {
cy.visit('/');
cy.get('[data-testid="count"]').should('have.text', 'Count: 0');
cy.get('button').contains('Increment').click();
cy.get('[data-testid="count"]').should('have.text', 'Count: 1');
cy.get('button').contains('Decrement').click();
cy.get('[data-testid="count"]').should('have.text', 'Count: 0');
});
});
Run npm start
to start the React app, then npm run cypress
. Cypress opens a browser and runs the tests, verifying the counter functionality.
Testing a Vue Application
In vue-test-demo
, add Cypress and configure similarly. Write cypress/e2e/counter.cy.js
:
describe('Counter', () => {
it('increments and decrements count', () => {
cy.visit('/');
cy.get('[data-testid="count"]').should('have.text', 'Count: 0');
cy.get('button').eq(0).click(); // Increment
cy.get('[data-testid="count"]').should('have.text', 'Count: 1');
cy.get('button').eq(1).click(); // Decrement
cy.get('[data-testid="count"]').should('have.text', 'Count: 0');
});
});
Run tests to simulate user clicks and verify the counter.
E2E Async Testing
Test the UserList
component:
describe('UserList', () => {
it('displays users after loading', () => {
cy.intercept('GET', 'https://jsonplaceholder.typicode.com/users', [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]).as('getUsers');
cy.visit('/');
cy.get('p').contains('Loading...').should('exist');
cy.wait('@getUsers');
cy.get('[data-testid="user-list"]').should('exist');
cy.get('[data-testid="user-list"]').contains('Alice').should('exist');
cy.get('[data-testid="user-list"]').contains('Bob').should('exist');
});
});
cy.intercept
mocks API responses, and cy.wait
waits for requests.
Complex Scenario: Form Testing
React Form
Create src/LoginForm.js
:
import React, { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email || !password) {
setError('All fields are required');
return;
}
setError('');
console.log('Submitted:', { email, password });
};
return (
<form onSubmit={handleSubmit}>
<input data-testid="email" value={email} onChange={e => setEmail(e.target.value)} />
<input data-testid="password" type="password" value={password} onChange={e => setPassword(e.target.value)} />
{error && <p data-testid="error">{error}</p>}
<button type="submit">Login</button>
</form>
);
}
export default LoginForm;
Unit test in src/LoginForm.test.js
:
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';
test('shows error for empty fields', () => {
render(<LoginForm />);
fireEvent.click(screen.getByText('Login'));
expect(screen.getByTestId('error')).toHaveTextContent('All fields are required');
});
test('submits valid form', () => {
const consoleSpy = jest.spyOn(console, 'log');
render(<LoginForm />);
fireEvent.change(screen.getByTestId('email'), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByTestId('password'), { target: { value: 'password123' } });
fireEvent.click(screen.getByText('Login'));
expect(screen.queryByTestId('error')).not.toBeInTheDocument();
expect(consoleSpy).toHaveBeenCalledWith('Submitted:', { email: 'test@example.com', password: 'password123' });
});
E2E test in cypress/e2e/login.cy.js
:
describe('LoginForm', () => {
it('shows error and submits form', () => {
cy.visit('/');
cy.get('button').contains('Login').click();
cy.get('[data-testid="error"]').should('have.text', 'All fields are required');
cy.get('[data-testid="email"]').type('test@example.com');
cy.get('[data-testid="password"]').type('password123');
cy.get('button').contains('Login').click();
cy.get('[data-testid="error"]').should('not.exist');
});
});
Vue Form
Create src/components/LoginForm.vue
:
<template>
<form @submit.prevent="handleSubmit">
<input data-testid="email" v-model="email" />
<input data-testid="password" type="password" v-model="password" />
<p v-if="error" data-testid="error">{{ error }}</p>
<button type="submit">Login</button>
</form>
</template>
<script>
export default {
data() {
return {
email: '',
password: '',
error: ''
};
},
methods: {
handleSubmit() {
if (!this.email || !this.password) {
this.error = 'All fields are required';
return;
}
this.error = '';
console.log('Submitted:', { email: this.email, password: this.password });
}
}
};
</script>
Unit test in tests/unit/LoginForm.spec.js
:
import { mount } from '@vue/test-utils';
import LoginForm from '@/components/LoginForm.vue';
describe('LoginForm', () => {
test('shows error for empty fields', async () => {
const wrapper = mount(LoginForm);
await wrapper.find('button').trigger('click');
expect(wrapper.find('[data-testid="error"]').text()).toBe('All fields are required');
});
test('submits valid form', async () => {
const consoleSpy = jest.spyOn(console, 'log');
const wrapper = mount(LoginForm);
await wrapper.find('[data-testid="email"]').setValue('test@example.com');
await wrapper.find('[data-testid="password"]').setValue('password123');
await wrapper.find('button').trigger('click');
expect(wrapper.find('[data-testid="error"]').exists()).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Submitted:', { email: 'test@example.com', password: 'password123' });
});
});
E2E tests follow the same pattern as the React Cypress example.
Conclusion (Technical Details)
Unit testing with Jest and Testing Library (React) or Vue Test Utils (Vue) verifies function and component logic, while E2E testing with Cypress simulates user interactions. The examples demonstrated:
- Jest for testing functions and mocking async requests.
- React Testing Library for rendering and interaction tests.
- Vue Test Utils for Vue component state updates.
- Cypress for full user flow testing.
Run these tests, debug with DevTools, and ensure your code is robust and fast!
Top comments (0)