TestCafe E2E Testing: No WebDriver Required Guide
Complete guide to TestCafe E2E testing without WebDriver. Covers TestCafe architecture, selectors, actions, assertions, roles for authentication, request mocking, and CI/CD setup.
TestCafe is an end-to-end testing framework that takes a fundamentally different approach from Selenium-based tools. Instead of controlling browsers through WebDriver or the DevTools Protocol, TestCafe injects scripts directly into the page under test. This means there is no external driver to install, no version compatibility to manage, and no browser-specific binaries to download.
This architecture choice gives TestCafe unique advantages: zero-configuration browser setup, automatic waiting, and the ability to test in any browser that runs JavaScript, including remote devices and cloud browsers. This guide covers TestCafe from setup through advanced patterns like roles, request mocking, and CI integration.
Key Takeaways
- TestCafe requires no WebDriver, browser plugins, or additional binaries
- Tests run by injecting a proxy script into the browser, giving TestCafe control over page behavior
- The selector API provides automatic waiting and retry logic for stable element queries
- Roles enable reusable authentication patterns across tests
- Request mocking (RequestMock and RequestLogger) lets you intercept and modify HTTP traffic
- TestCafe supports Chrome, Firefox, Safari, Edge, and remote browsers out of the box
How TestCafe Works
TestCafe acts as a reverse proxy between the browser and the application:
Browser <-> TestCafe Proxy <-> Your Application
When you run a test, TestCafe:
- Starts a local proxy server
- Opens the browser pointing to the proxy URL
- Injects test scripts into every page the browser loads
- Intercepts requests and responses to enable mocking and assertions
- Reports results back to the test runner
This design eliminates the driver compatibility issues that plague Selenium-based tools. If a browser can load web pages, TestCafe can test in it.
Setup and Installation
Installation
npm install -D testcafe
That is the entire setup. No browser drivers, no Java, no Selenium. TestCafe uses browsers already installed on your machine.
Configuration
Create a .testcaferc.json file:
{
"browsers": ["chrome:headless"],
"src": "tests/e2e/**/*.test.js",
"screenshots": {
"path": "tests/screenshots",
"takeOnFails": true,
"fullPage": true
},
"reporter": [
{ "name": "spec" },
{ "name": "xunit", "output": "reports/results.xml" }
],
"concurrency": 3,
"retryTestPages": true,
"pageLoadTimeout": 10000,
"assertionTimeout": 5000,
"selectorTimeout": 10000
}
Running Tests
# Run all tests in Chrome
npx testcafe chrome tests/
# Headless Chrome
npx testcafe chrome:headless tests/
# Multiple browsers
npx testcafe chrome,firefox tests/
# Specific test file
npx testcafe chrome tests/login.test.js
# Run with concurrency (3 browser instances)
npx testcafe chrome tests/ -c 3
# Live mode (re-runs on file changes)
npx testcafe chrome tests/ --live
Writing Tests
TestCafe tests use a fixture/test structure with async/await:
import { Selector } from 'testcafe';
fixture('Homepage')
.page('https://example.com');
test('Page loads with correct title', async t => {
await t.expect(Selector('h1').innerText).eql('Welcome');
});
test('Navigation links are visible', async t => {
const navLinks = Selector('nav a');
await t
.expect(navLinks.count).gte(3)
.expect(navLinks.nth(0).innerText).eql('Home')
.expect(navLinks.nth(1).innerText).eql('About')
.expect(navLinks.nth(2).innerText).eql('Contact');
});
Fixtures and Test Organization
Fixtures group related tests and can set shared configuration:
fixture('User Dashboard')
.page('https://example.com/dashboard')
.beforeEach(async t => {
// Runs before each test in this fixture
await t.useRole(adminRole);
})
.afterEach(async t => {
// Runs after each test in this fixture
await t.eval(() => localStorage.clear());
});
test('Shows user statistics', async t => {
await t
.expect(Selector('.stats-panel').visible).ok()
.expect(Selector('.total-users').innerText).notEql('0');
});
test('Recent activity list is populated', async t => {
await t
.expect(Selector('.activity-list .item').count).gt(0);
});
Selectors
TestCafe selectors automatically wait for elements to appear in the DOM and retry queries until the timeout expires.
Basic Selectors
import { Selector } from 'testcafe';
// CSS selector
const submitButton = Selector('button[type="submit"]');
// By ID
const header = Selector('#main-header');
// By class
const errorMessages = Selector('.error-message');
// By tag name
const allLinks = Selector('a');
// Nested selectors
const menuItems = Selector('.sidebar').find('.menu-item');
Filtering Selectors
const items = Selector('.list-item');
// By index
const firstItem = items.nth(0);
const lastItem = items.nth(-1);
// By text content
const activeItem = items.withText('Active');
const exactMatch = items.withExactText('Active Users');
// By attribute
const checkedBoxes = Selector('input[type="checkbox"]')
.withAttribute('checked');
// Filter with a function
const largeItems = items.filter(node => {
return node.offsetHeight > 100;
});
// Parent/child traversal
const parent = Selector('.child').parent('.wrapper');
const siblings = Selector('.target').sibling('.related');
Custom Selectors
// Select by React component (with testcafe-react-selectors)
import { ReactSelector } from 'testcafe-react-selectors';
const todoItem = ReactSelector('TodoItem');
const completedTodos = ReactSelector('TodoItem')
.withProps({ completed: true });
// Select by test ID (recommended pattern)
const loginButton = Selector('[data-testid="login-button"]');
Actions
TestCafe provides a rich set of actions that automatically wait for elements to be actionable.
Click and Type
test('Fill out and submit a form', async t => {
await t
.click(Selector('input[name="email"]'))
.typeText(Selector('input[name="email"]'), 'jane@example.com')
.typeText(Selector('input[name="password"]'), 'securePass123')
.click(Selector('button[type="submit"]'));
});
Advanced Input Actions
test('Form interactions', async t => {
// Clear field before typing
await t.typeText('#search', 'new query', { replace: true });
// Type character by character (triggers keydown/keyup events)
await t.typeText('#search', 'slow typing', { speed: 0.1 });
// Press keyboard keys
await t.pressKey('enter');
await t.pressKey('ctrl+a delete');
// Select from dropdown
await t.click('#country-select')
.click(Selector('option').withText('United States'));
// Checkboxes and radio buttons
await t.click('#agree-checkbox');
await t.click('input[value="premium"]');
});
Drag and Drop
test('Drag and drop items', async t => {
const draggable = Selector('.drag-item');
const dropZone = Selector('.drop-zone');
await t.dragToElement(draggable, dropZone);
});
File Upload
test('Upload a file', async t => {
await t.setFilesToUpload(
Selector('input[type="file"]'),
['./test-files/document.pdf']
);
await t.expect(Selector('.upload-success').visible).ok();
});
Scrolling
test('Scroll to element', async t => {
const footer = Selector('footer');
await t.scrollIntoView(footer);
await t.expect(footer.visible).ok();
// Scroll by offset
await t.scroll(0, 500);
});
Assertions
TestCafe assertions use the expect API with built-in waiting:
Value Assertions
test('Assertion examples', async t => {
const title = Selector('h1');
const count = Selector('.item-count');
const price = Selector('.price');
// String assertions
await t.expect(title.innerText).eql('Dashboard');
await t.expect(title.innerText).contains('Dash');
await t.expect(title.innerText).notEql('Login');
await t.expect(title.innerText).match(/^Dash/);
// Numeric assertions
await t.expect(count.innerText).eql('42');
// Boolean assertions
await t.expect(title.visible).ok();
await t.expect(title.visible).ok('Title should be visible');
await t.expect(Selector('.error').visible).notOk();
});
Element Property Assertions
test('Element properties', async t => {
const input = Selector('#email');
const button = Selector('#submit');
// Check element exists
await t.expect(input.exists).ok();
// Check attributes
await t.expect(input.getAttribute('type')).eql('email');
await t.expect(input.getAttribute('placeholder'))
.contains('Enter email');
// Check CSS
await t.expect(button.getStyleProperty('background-color'))
.eql('rgb(0, 123, 255)');
// Check element count
await t.expect(Selector('.list-item').count).eql(5);
// Check value of input
await t.expect(input.value).eql('');
await t.typeText(input, 'test@example.com');
await t.expect(input.value).eql('test@example.com');
});
Custom Assertion Timeout
// Override timeout for a specific assertion
await t.expect(Selector('.slow-content').visible)
.ok('Content should load', { timeout: 30000 });
Roles for Authentication
Roles let you define reusable authentication states. TestCafe caches the browser state (cookies, localStorage) after the first login and restores it for subsequent uses, making tests faster.
Defining Roles
import { Role, Selector } from 'testcafe';
const adminRole = Role('https://example.com/login', async t => {
await t
.typeText('#email', 'admin@example.com')
.typeText('#password', 'adminPass123')
.click('#login-button');
}, { preserveUrl: true });
const userRole = Role('https://example.com/login', async t => {
await t
.typeText('#email', 'user@example.com')
.typeText('#password', 'userPass123')
.click('#login-button');
}, { preserveUrl: true });
const anonymousRole = Role.anonymous();
Using Roles in Tests
fixture('Admin Dashboard')
.page('https://example.com/dashboard');
test('Admin sees admin panel', async t => {
await t.useRole(adminRole);
await t
.expect(Selector('.admin-panel').visible).ok()
.expect(Selector('.user-role').innerText).eql('Admin');
});
test('Regular user does not see admin panel', async t => {
await t.useRole(userRole);
await t
.expect(Selector('.admin-panel').exists).notOk()
.expect(Selector('.user-role').innerText).eql('User');
});
test('Anonymous user is redirected to login', async t => {
await t.useRole(anonymousRole);
await t.expect(Selector('#login-form').visible).ok();
});
test('Switch roles mid-test', async t => {
await t.useRole(userRole);
await t.expect(Selector('.dashboard').visible).ok();
await t.useRole(adminRole);
await t.expect(Selector('.admin-panel').visible).ok();
await t.useRole(anonymousRole);
await t.expect(Selector('#login-form').visible).ok();
});
Request Mocking
TestCafe provides RequestMock for intercepting HTTP requests and RequestLogger for monitoring them.
RequestMock
import { RequestMock, Selector } from 'testcafe';
const mockUsers = RequestMock()
.onRequestTo('https://api.example.com/users')
.respond([
{ id: 1, name: 'Jane Doe' },
{ id: 2, name: 'John Smith' },
], 200, {
'content-type': 'application/json',
'access-control-allow-origin': '*',
});
const mockError = RequestMock()
.onRequestTo('https://api.example.com/users')
.respond(
{ error: 'Internal Server Error' },
500,
{ 'content-type': 'application/json' }
);
fixture('User List')
.page('https://example.com/users');
test
.requestHooks(mockUsers)
('Displays mocked user list', async t => {
await t
.expect(Selector('.user-item').count).eql(2)
.expect(Selector('.user-item').nth(0).innerText)
.contains('Jane Doe');
});
test
.requestHooks(mockError)
('Shows error state on API failure', async t => {
await t
.expect(Selector('.error-message').visible).ok()
.expect(Selector('.error-message').innerText)
.contains('Something went wrong');
});
RequestLogger
import { RequestLogger, Selector } from 'testcafe';
const apiLogger = RequestLogger(
'https://api.example.com/analytics',
{
logRequestBody: true,
logResponseBody: true,
stringifyRequestBody: true,
}
);
fixture('Analytics Tracking')
.page('https://example.com')
.requestHooks(apiLogger);
test('Sends analytics event on button click', async t => {
await t.click(Selector('.track-button'));
// Wait for the request to be logged
await t.expect(apiLogger.count(
r => r.request.method === 'post'
)).eql(1);
const request = apiLogger.requests[0];
const body = JSON.parse(request.request.body);
await t.expect(body.event).eql('button_click');
await t.expect(body.page).eql('/');
});
Conditional Mocking
const conditionalMock = RequestMock()
.onRequestTo(request => {
return request.url.includes('/api/')
&& request.method === 'post';
})
.respond((req, res) => {
const body = JSON.parse(req.body);
if (body.email === 'existing@example.com') {
res.statusCode = 409;
res.headers['content-type'] = 'application/json';
res.setBody(JSON.stringify({
error: 'User already exists'
}));
} else {
res.statusCode = 201;
res.headers['content-type'] = 'application/json';
res.setBody(JSON.stringify({
id: 1, email: body.email
}));
}
});
Client-Side JavaScript
Execute JavaScript directly in the browser context:
import { ClientFunction, Selector } from 'testcafe';
const getPageUrl = ClientFunction(() => window.location.href);
const getLocalStorageItem = ClientFunction(
key => localStorage.getItem(key)
);
const scrollToBottom = ClientFunction(
() => window.scrollTo(0, document.body.scrollHeight)
);
fixture('Client Functions')
.page('https://example.com');
test('URL changes after navigation', async t => {
await t.click(Selector('a').withText('About'));
const url = await getPageUrl();
await t.expect(url).contains('/about');
});
test('Check localStorage value', async t => {
await t.click(Selector('#save-preference'));
const theme = await getLocalStorageItem('theme');
await t.expect(theme).eql('dark');
});
CI/CD Integration
GitHub Actions
name: E2E Tests
on: [push, pull_request]
jobs:
testcafe:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Start application
run: npm start &
env:
PORT: 3000
- name: Wait for application
run: npx wait-on http://localhost:3000
- name: Run TestCafe tests
run: npx testcafe chrome:headless tests/
--reporter spec,xunit:reports/results.xml
--screenshots tests/screenshots
--screenshots-on-fails
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: testcafe-artifacts
path: |
tests/screenshots/
reports/
Docker
FROM node:20-slim
RUN apt-get update && apt-get install -y \
chromium \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV CHROMIUM_PATH=/usr/bin/chromium
CMD ["npx", "testcafe", "chromium:headless", "tests/"]
Concurrency and Parallel Execution
TestCafe supports concurrent test execution with a simple flag:
# Run tests in 3 browser instances simultaneously
npx testcafe chrome tests/ -c 3
{
"concurrency": 3,
"browsers": ["chrome:headless"]
}
This launches multiple browser windows that each pick up the next available test from the queue. Unlike other tools that require complex configuration for parallelism, TestCafe handles it with a single number.
Parallel Across Browsers
# Run in Chrome and Firefox simultaneously
npx testcafe chrome,firefox tests/ -c 2
Debugging
Debug Mode
test('Debug a failing test', async t => {
await t.navigateTo('https://example.com');
// Pause execution and open browser DevTools
await t.debug();
await t.click('#submit');
});
Live Mode
# Re-runs tests on file changes
npx testcafe chrome tests/ --live
Screenshots and Videos
{
"screenshots": {
"path": "tests/screenshots",
"takeOnFails": true,
"fullPage": true,
"pathPattern": "\${DATE}_\${TIME}/\${FIXTURE}/\${TEST}/\${FILE_INDEX}.png"
},
"videoPath": "tests/videos",
"videoOptions": {
"failedOnly": true,
"pathPattern": "\${DATE}_\${TIME}/\${FIXTURE}/\${TEST}.mp4"
}
}
When to Choose TestCafe
TestCafe is a good fit when:
- You want zero setup for browser automation (no drivers to install or manage)
- You need to test in browsers on remote devices or cloud services
- You value the simplicity of a single
npm installfor the entire testing framework - The role-based authentication pattern fits your application
- You need request mocking without additional libraries
- Your team prefers a proxy-based architecture over WebDriver or CDP
Consider alternatives when:
- You need the deepest possible browser control (Playwright or Puppeteer via CDP)
- You want built-in component testing (Cypress has first-class support)
- You need the trace viewer debugging experience (Playwright)
- Your team is already invested in a WebDriver ecosystem (Selenium, Nightwatch)
Summary
TestCafe eliminates the biggest pain point of browser testing: driver management. By injecting scripts through a proxy, it works with any browser without external dependencies. The selector API with automatic waiting, roles for authentication, and request mocking for network control provide everything you need for comprehensive E2E testing. Combined with simple concurrency and straightforward CI integration, TestCafe remains a practical and productive choice for teams that want to write end-to-end tests without fighting infrastructure.