Unit Tests vs UI Tests: When and Why to Draw the Line

Alejandro Rey Juarez
4 min readJan 15, 2025

--

1. Introduction

Have you ever written a “unit test” that tries to click a button, confirm a DOM change, then thought, “Wait, is this really unit testing?” You’re not alone. Developers often mix up unit-tests — which verify the logic of a single function or class — with UI tests that verify visual elements and user interactions.

In this article, we’ll explore why unit tests should focus on core logic (like data manipulation and method calls), leaving the button-clicking and screen validations to integration or end-to-end (E2E) testing tools like Playwright or Cypress. By drawing a clear line, you’ll end up with a cleaner, more maintainable test suite that covers all the essentials aspects of your app without duplicating efforts.

2. What Are Unit Tests, Really?

Definition

A unit test verifies the smallest piece of functionality — typically a function, method, or class — in isolation. As defined by Robert C. Martin (Clean Code), a good unit test is fast, focused, and repeatable.

Key Characteristics

  • Fast: Unit test run quickly by avoiding external dependencies like servers, browsers, or real databases.
  • Focused: They test a single behavior, validating logic without concern for UI rendering or styling.
  • Deterministic: External dependencies (e.g. databases, network calls) are mocked or stubbed, ensuring consistent results.

Example: A Simple “Counter” Function

const createCounter = () => {
let count = 0;
return {
increment: () => ++count,
getValue: () => count
};
};

A simple set of unit tests for createCounter might look like this in Jest:

describe('createCounter', () => {
it('should have an initial value of zero', () => {
// Given.
const counter = createCounter();

// When.
const response = counter.getValue();

// Then.
expect(response).toBe(0);
});

describe('increment', () => {
it('should increase the current count', () => {
// Given.
const counter = createCounter();

// When.
counter.increment();

// Then.
expect(counter.getValue()).toBe(1);
});
});
});

Notice we’re testing logic — the behavior of increment() — rather than “clicking a button” or interacting with any UI element.

3. The Problem with Testing UI Interactions in Unit Tests

1. Brittle Tests

  • If you rely on DOM elements that might change (e.g., renaming a CSS class, rearranging layout), your “unit” tests can break, even though the core logic remains correct.

2. Slower Feedback Loop

  • Unit tests are meant to be quick. Once you add actual rendering or DOM interactions, you slow things down, potentially requiring headless browsers or extra environment setups.

3. Overlapping Coverage

  • UI tests (like integration or E2E) already verify clicks, renders, and on-screen results. Repeating those checks in unit tests is redundant — and can lead to double maintenance work.

4. Where UI Tests Fit: Integration & E2E

Integration Tests

  • Validate multiple parts of the application working together (e.g. a parent and child component, or a function that calls an API).
  • Often use libraries that render small chunks of the UI in a test environment but not a real browser.

E2E Tests

  • Tools like Playwright, Cypress, or Puppeteer run your app in a headless (or real) browser.
  • They confirm that when you actually click a button on-screen, the counter or display changes.
  • Simulate real user flows — page loads, button clicks, form submissions, navigation.

Example: A Simple E2E Test with Playwright

test('incremenets the counter when the button is clicked', async ({ page }) => {
await page.goto('http://localhost:3000');

// Assume there's an element with class .counter-display
expect(await page.textContent('.counter-display')).toBe('0');

// Click the button that should increment the counter
await page.click('.btn-increment');

expect(await page.textContent('.counter-display')).toBe('1');
});

Here, the actual rendering, clicking, and user interaction logic are test end-to-end — exactly where those checks belong.

5. Benefits of Separating Logic Tests from UI Tests

1. Speed and Simplicity

  • Unit tests run in milliseconds, focusing on pure code.
  • This fast feedback loop is crucial for catching regressions early.

2. Better Maintenance

  • If a developer renames a CSS class or reorders elements in the UI, your unit tests remain unaffected.
  • Conversely, your E2E tests might break, but that’s expected because they actually care about the UI structure.

3. Clear Ownership

  • Front-end devs or QA teams often handle E2E testing.
  • Core logic can be tested by any dev who works on the business rules or data transformations.

6. Best Practices to Keep Unit Tests “Pure”

1. Mock Everything External

  • If your function calls an API or interacts with local storage, mock or stub those parts so you don’t rely on real external systems.

2. Avoid DOM Queries

  • Don’t document.querySelector() or similar in your unit tests. If you see that, it’s a sign you might be mixing up test types.

3. One Purpose per Test

  • Each unit test should verify one behavior: “should have expected default value”, “should increase the value”, etc…

4. Meaningful Names

  • Clarity is key. “should increase the current count” is more descriptive than “test increase”.

7. Conclusion

Unit tests are all about logic, speed, and focus. They ensure your core functions, classes, and small modules behave as intended. UI and interaction testing belongs in integration or E2E tests, which actually spin up a browser environment (or a close equivalent).

By separating these testing layers, you get faster, more reliable unit tests and robust UI tests that validate real user flows. It’s a win-win that saves time, reduces frustration, and ensures better coverage for every aspect of your front-end application.

Next Steps

  • Audit Your Current Tests: See where you might be mixing DOM checks into unit tests.
  • Refactor: Extract pure logic tests to keep them independent from UI concerns.
  • Try an E2E Tool: If you’re not already using Playwright, Cypress, or similar, give one a shot for the UI-heavy scenarios.

Thanks for reading! If you have questions, challenges, or just want to share how you’re splitting your test layers, drop a comment below. Let’s help each other build better tests — faster, simpler and more meaningful.

--

--

Alejandro Rey Juarez
Alejandro Rey Juarez

Written by Alejandro Rey Juarez

Senior Software Engineer building Generative AI, full-stack apps, and orchestrating DevOps. Sharing insights, code, and real-world war stories.

No responses yet