DEV Community

Tianya School
Tianya School

Posted on

Frontend Unit Testing and End-to-End Testing Strategies

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
Enter fullscreen mode Exit fullscreen mode

Configure package.json:

{
  "scripts": {
    "test": "jest"
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add jest-dom to src/setupTests.js:

import '@testing-library/jest-dom';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Configure package.json:

{
  "scripts": {
    "test": "jest"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create jest.config.js:

module.exports = {
  preset: '@vue/cli-plugin-unit-jest'
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create cypress.config.js:

module.exports = {
  e2e: {
    baseUrl: 'http://localhost:3000'
  }
};
Enter fullscreen mode Exit fullscreen mode

Add to package.json:

{
  "scripts": {
    "cypress": "cypress open"
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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' });
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' });
  });
});
Enter fullscreen mode Exit fullscreen mode

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)