TestCafe Tutorial: Cross-Browser Testing Without WebDriver
Most E2E testing frameworks depend on WebDriver or browser-specific binaries. TestCafe takes a completely different approach: it injects a proxy server between your test script and the browser, injecting automation scripts directly into web pages. No WebDriver. No browser plugins. Just Node.js and a URL.
This architectural choice has practical benefits — TestCafe works on any browser that can open a URL, including mobile browsers and non-standard environments. This tutorial shows you how to take advantage of that.
Why TestCafe's Architecture Matters
Traditional WebDriver-based tools (Selenium, Nightwatch, WebdriverIO) rely on browser-specific drivers (ChromeDriver, GeckoDriver) to send commands to browsers. These drivers need constant updates as browsers release new versions, and the WebDriver protocol can be slow.
TestCafe works differently:
- Launches a proxy server
- Opens your target URL through that proxy
- Injects test scripts directly into the page
- Communicates with scripts via the proxy
Result: No external drivers. Any browser that can open a URL works — including browsers on remote devices, BrowserStack, or even iOS Safari.
Installation
npm install testcafe --save-devAdd scripts to package.json:
{
"scripts": {
"test": "testcafe chrome tests/",
"test:headless": "testcafe chrome:headless tests/",
"test:firefox": "testcafe firefox tests/",
"test:all": "testcafe chrome,firefox tests/ --parallel"
}
}No additional setup required. TestCafe downloads everything it needs on first run.
Your First TestCafe Test
TestCafe uses its own test runner syntax with fixture and test functions:
// tests/homepage.test.js
import { Selector } from 'testcafe';
fixture('Homepage')
.page('https://example.com');
test('Page loads with correct title', async t => {
await t
.expect(Selector('title').innerText).contains('Example Domain');
});
test('Navigation links are present', async t => {
const links = Selector('nav a');
await t
.expect(links.count).gt(0)
.expect(Selector('nav').visible).ok();
});Run:
npx testcafe chrome tests/homepage.test.jsTestCafe opens Chrome, runs the tests, closes Chrome, and prints results.
Selectors
TestCafe's Selector API is one of its strongest features. Selectors are lazy — they don't query the DOM until you use them in an assertion or action:
import { Selector } from 'testcafe';
// Basic selectors
const title = Selector('h1');
const submitButton = Selector('button[type="submit"]');
const emailInput = Selector('#email');
// CSS selectors
const firstItem = Selector('.list-item:first-child');
const activeTab = Selector('.tab.active');
// Text-based
const loginLink = Selector('a').withText('Log In');
const submitBtn = Selector('button').withText('Submit');
// Attribute-based
const checkbox = Selector('[data-testid="agree-checkbox"]');
// DOM traversal
const errorInForm = Selector('form').find('.error-message');
const parentSection = Selector('.child-element').parent('section');
const nextSibling = Selector('.item').nextSibling();
// Nth element
const thirdRow = Selector('tr').nth(2); // 0-indexedReact/Angular/Vue component selectors:
npm install testcafe-react-selectorsimport { ReactSelector } from 'testcafe-react-selectors';
const counter = ReactSelector('Counter');
const button = ReactSelector('Counter').find('button');Actions
test('Form submission', async t => {
await t
// Click
.click('#submit-btn')
.click(Selector('button').withText('Cancel'))
// Typing
.typeText('#email', 'user@example.com')
.typeText('#search', 'query', { replace: true }) // clear first
// Clearing
.selectText('#input').pressKey('delete')
// Keyboard
.pressKey('enter')
.pressKey('ctrl+a')
.pressKey('tab tab tab enter') // multiple keys
// Hover
.hover('#dropdown-trigger')
// Drag
.drag('#slider', 100, 0) // offset in pixels
.dragToElement('#draggable', '#droptarget')
// Scrolling
.scroll('#long-list', 'bottom')
.scrollIntoView('#target-element')
// File upload
.setFilesToUpload('#file-input', ['path/to/file.pdf'])
// Select
.click(Selector('select option').withText('Option 2'));
});Assertions
TestCafe assertions are async by default and automatically wait for conditions to be met:
test('Assertions demo', async t => {
// Existence and visibility
await t.expect(Selector('#modal').exists).ok();
await t.expect(Selector('#modal').visible).ok();
await t.expect(Selector('#overlay').visible).notOk();
// Text
await t.expect(Selector('h1').innerText).eql('Welcome');
await t.expect(Selector('.message').innerText).contains('success');
// Attributes
await t.expect(Selector('#link').getAttribute('href')).contains('/dashboard');
await t.expect(Selector('#checkbox').checked).ok();
await t.expect(Selector('#input').value).eql('test@example.com');
// Counts
await t.expect(Selector('.item').count).eql(5);
await t.expect(Selector('.error').count).gt(0);
// URL
await t.expect(t.eval(() => window.location.href)).contains('/success');
});Smart waits: TestCafe retries assertions automatically until they pass or a timeout (default 3s) expires. You rarely need explicit wait calls.
Fixtures and Hooks
Group related tests in fixtures with lifecycle hooks:
fixture('User Account')
.page('https://app.example.com')
.beforeEach(async t => {
// Runs before each test in this fixture
await t
.navigateTo('/login')
.typeText('#email', 'user@example.com')
.typeText('#password', 'password123')
.click('button[type="submit"]')
.expect(Selector('.dashboard').visible).ok({ timeout: 5000 });
})
.afterEach(async t => {
// Cleanup
await t.navigateTo('/logout');
});
test('User can update profile', async t => {
// Already logged in from beforeEach
await t.navigateTo('/settings');
// ...
});
test('User can view order history', async t => {
await t.navigateTo('/orders');
// ...
});Page Models
Organize selectors and actions into reusable page models:
// models/LoginPage.js
import { Selector, t } from 'testcafe';
class LoginPage {
constructor() {
this.emailInput = Selector('#email');
this.passwordInput = Selector('#password');
this.submitButton = Selector('button[type="submit"]');
this.errorMessage = Selector('.error-message');
this.successMessage = Selector('.success-message');
}
async login(email, password) {
await t
.typeText(this.emailInput, email)
.typeText(this.passwordInput, password)
.click(this.submitButton);
}
async assertError(message) {
await t
.expect(this.errorMessage.visible).ok()
.expect(this.errorMessage.innerText).contains(message);
}
}
export default new LoginPage();// tests/login.test.js
import { fixture, test } from 'testcafe';
import loginPage from '../models/LoginPage';
fixture('Login').page('https://app.example.com');
test('Valid credentials log in successfully', async t => {
await loginPage.login('user@example.com', 'password123');
await t.expect(Selector('.dashboard').exists).ok();
});
test('Wrong password shows error', async t => {
await loginPage.login('user@example.com', 'wrongpassword');
await loginPage.assertError('Invalid credentials');
});Running in Multiple Browsers
# Single browser
testcafe chrome tests/
<span class="hljs-comment"># Multiple browsers (sequential)
testcafe chrome,firefox tests/
<span class="hljs-comment"># Parallel across browsers
testcafe chrome,firefox tests/ --parallel
<span class="hljs-comment"># Headless
testcafe chrome:headless tests/
testcafe firefox:headless tests/
<span class="hljs-comment"># Remote/BrowserStack
testcafe <span class="hljs-string">"browserstack:Chrome@latest:Windows 10" tests/CI/CD Integration
GitHub Actions:
name: TestCafe E2E
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- name: Run TestCafe tests
run: npx testcafe chrome:headless tests/ --reporter spec,junit:report.xml
- name: Upload test report
uses: actions/upload-artifact@v3
if: always()
with:
name: test-report
path: report.xmlJenkins:
pipeline {
agent any
stages {
stage('Install') {
steps { sh 'npm ci' }
}
stage('E2E Tests') {
steps {
sh 'npx testcafe chrome:headless tests/ --reporter junit:report.xml'
}
post {
always {
junit 'report.xml'
}
}
}
}
}TestCafe Configuration File
Create .testcaferc.json in your project root:
{
"browsers": ["chrome:headless"],
"src": ["tests/**/*.test.js"],
"reporter": [
{ "name": "spec" },
{ "name": "junit", "output": "reports/junit.xml" }
],
"screenshots": {
"path": "reports/screenshots",
"takeOnFails": true,
"fullPage": true
},
"videoPath": "reports/videos",
"assertionTimeout": 5000,
"selectorTimeout": 5000,
"pageLoadTimeout": 30000,
"concurrency": 2
}TestCafe + HelpMeTest for Production Monitoring
TestCafe handles pre-deployment testing, but production monitoring is a separate concern. Running TestCafe tests continuously in production is resource-intensive and not what it's designed for.
HelpMeTest fills this gap — it runs automated browser tests 24/7 against production endpoints with 5-minute monitoring intervals, alerting you when real user flows break after deployment.
curl -fsSL https://helpmetest.com/install | bash
helpmetest loginThe combination: TestCafe catches bugs in CI before they ship; HelpMeTest catches incidents in production after they ship.
Common Issues and Fixes
Tests fail randomly:
- Increase
assertionTimeoutin config (default 3000ms) - Use
Selector().filterVisible()for elements that appear/disappear - Avoid
t.wait()— use assertion timeouts instead
Selector not found:
- Check if element is inside an iframe: use
ClientFunctionto inspect - Verify CSS selector in browser DevTools first
- For dynamic content, use
.withText()or.withAttribute()instead of positional selectors
Slow test execution:
- Enable
concurrencyin config - Use
--parallelflag - Consider
chrome:headless— it's 30-40% faster than headed Chrome
Conclusion
TestCafe's proxy-based architecture eliminates the WebDriver maintenance burden while supporting any browser. Its automatic waiting, rich Selector API, and zero-configuration startup make it productive from day one.
Start with the quick install, write a fixture for your most critical user flow, and expand from there. The built-in TypeScript support and page model pattern make TestCafe suites maintainable as your application grows.