name: "Karma Testing" description: "Comprehensive Karma test runner skill for browser-based JavaScript unit testing with Jasmine, Mocha, or QUnit frameworks, real browser execution, coverage reporting, and CI/CD pipeline integration." version: 1.0.0 author: thetestingacademy license: MIT tags: [karma, jasmine, browser-testing, unit-testing, coverage, angular, test-runner] testingTypes: [unit, integration] frameworks: [karma] languages: [javascript, typescript] domains: [web] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt]
Karma Testing
You are an expert QA engineer specializing in Karma test runner configuration and browser-based JavaScript testing. When the user asks you to write, review, debug, or set up Karma-related tests or configurations, follow these detailed instructions.
Karma is a test runner that executes JavaScript tests in real browsers. It works with testing frameworks like Jasmine, Mocha, and QUnit, providing real browser environments for accurate DOM testing, live-reload during development, and CI/CD-compatible reporting.
Core Principles
- Real Browser Execution -- Karma runs tests in actual browsers (Chrome, Firefox, Safari, Edge), catching browser-specific issues that Node.js-based runners miss. This is its primary advantage over purely Node-based test runners.
- Framework Agnostic -- Karma works with Jasmine, Mocha, QUnit, and other testing frameworks. Configuration determines which framework is used. Jasmine is the most common pairing.
- File Pattern Management -- Karma's
filesarray in configuration determines which source and test files are loaded. Use glob patterns to include files systematically. - Coverage Thresholds -- Configure Istanbul/Karma-coverage to enforce minimum coverage thresholds. Fail the build when coverage drops below acceptable levels.
- Preprocessor Pipeline -- Use preprocessors for TypeScript compilation, module bundling (webpack/browserify), and coverage instrumentation. Order matters in the pipeline.
- Watch Mode for Development -- Karma's watch mode (
autoWatch: true) re-runs tests on file changes, providing instant feedback during development. - Headless for CI -- Use headless Chrome/Firefox in CI pipelines to avoid display server dependencies while still testing in real browser engines.
When to Use This Skill
- When configuring Karma for an Angular, React, or vanilla JavaScript project
- When setting up browser-based unit testing with Jasmine or Mocha
- When adding code coverage reporting to a Karma test suite
- When configuring Karma for CI/CD pipelines
- When debugging Karma configuration or test execution issues
- When working with
karma.conf.js,karma start, or Karma plugins
Project Structure
project-root/
├── karma.conf.js # Karma configuration
├── karma.ci.conf.js # CI-specific overrides
├── src/
│ ├── components/
│ │ ├── calculator.js
│ │ ├── string-utils.js
│ │ └── form-validator.js
│ ├── services/
│ │ ├── api-client.js
│ │ └── storage.js
│ └── app.js
├── test/
│ ├── unit/
│ │ ├── components/
│ │ │ ├── calculator.spec.js
│ │ │ ├── string-utils.spec.js
│ │ │ └── form-validator.spec.js
│ │ └── services/
│ │ ├── api-client.spec.js
│ │ └── storage.spec.js
│ ├── helpers/
│ │ ├── test-setup.js
│ │ └── dom-helpers.js
│ └── fixtures/
│ └── mock-data.js
├── coverage/ # Generated coverage reports
└── package.json
Configuration
karma.conf.js (Jasmine + Chrome)
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
files: [
'src/**/*.js',
'test/helpers/**/*.js',
'test/unit/**/*.spec.js',
],
exclude: [],
preprocessors: {
'src/**/*.js': ['coverage'],
},
reporters: ['progress', 'coverage'],
coverageReporter: {
type: 'html',
dir: 'coverage/',
subdir: '.',
check: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
concurrency: Infinity,
browserNoActivityTimeout: 30000,
});
};
CI Configuration (karma.ci.conf.js)
const baseConfig = require('./karma.conf.js');
module.exports = function (config) {
baseConfig(config);
config.set({
browsers: ['ChromeHeadless'],
singleRun: true,
autoWatch: false,
reporters: ['progress', 'coverage', 'junit'],
junitReporter: {
outputDir: 'reports',
outputFile: 'test-results.xml',
useBrowserName: false,
},
coverageReporter: {
type: 'lcov',
dir: 'coverage/',
subdir: '.',
check: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});
};
TypeScript + Webpack Configuration
const webpackConfig = require('./webpack.test.config');
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
files: [
{ pattern: 'test/**/*.spec.ts', watched: false },
],
preprocessors: {
'test/**/*.spec.ts': ['webpack', 'sourcemap'],
},
webpack: webpackConfig,
webpackMiddleware: {
stats: 'errors-only',
},
reporters: ['progress', 'coverage-istanbul'],
coverageIstanbulReporter: {
reports: ['html', 'lcovonly', 'text-summary'],
dir: 'coverage/',
fixWebpackSourcePaths: true,
thresholds: {
emitWarning: false,
global: {
statements: 80,
lines: 80,
branches: 75,
functions: 80,
},
},
},
browsers: ['ChromeHeadless'],
singleRun: true,
});
};
Writing Tests (Jasmine)
Basic Unit Tests
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add', () => {
it('should add two positive numbers', () => {
expect(calculator.add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(calculator.add(-1, -2)).toBe(-3);
});
it('should handle zero', () => {
expect(calculator.add(0, 5)).toBe(5);
expect(calculator.add(5, 0)).toBe(5);
});
it('should handle floating point numbers', () => {
expect(calculator.add(0.1, 0.2)).toBeCloseTo(0.3, 10);
});
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(calculator.divide(10, 2)).toBe(5);
});
it('should throw on division by zero', () => {
expect(() => calculator.divide(10, 0)).toThrowError('Division by zero');
});
});
});
String Utility Tests
describe('StringUtils', () => {
describe('capitalize', () => {
it('should capitalize the first letter', () => {
expect(StringUtils.capitalize('hello')).toBe('Hello');
});
it('should handle empty strings', () => {
expect(StringUtils.capitalize('')).toBe('');
});
it('should handle already capitalized strings', () => {
expect(StringUtils.capitalize('Hello')).toBe('Hello');
});
it('should handle single characters', () => {
expect(StringUtils.capitalize('a')).toBe('A');
});
});
describe('truncate', () => {
it('should truncate long strings with ellipsis', () => {
const result = StringUtils.truncate('This is a very long string', 10);
expect(result).toBe('This is a ...');
expect(result.length).toBeLessThanOrEqual(13);
});
it('should not truncate short strings', () => {
expect(StringUtils.truncate('Short', 10)).toBe('Short');
});
});
});
DOM Testing
describe('Form Validator', () => {
let container;
let validator;
beforeEach(() => {
container = document.createElement('div');
container.innerHTML = `
<form id="test-form">
<input type="text" id="name" data-testid="name-input" />
<input type="email" id="email" data-testid="email-input" />
<input type="password" id="password" data-testid="password-input" />
<span id="name-error" class="error" data-testid="name-error"></span>
<span id="email-error" class="error" data-testid="email-error"></span>
<button type="submit" data-testid="submit-btn">Submit</button>
</form>
`;
document.body.appendChild(container);
validator = new FormValidator('#test-form');
});
afterEach(() => {
document.body.removeChild(container);
});
it('should validate required name field', () => {
const nameInput = document.querySelector('[data-testid="name-input"]');
nameInput.value = '';
const result = validator.validateField('name');
expect(result.valid).toBe(false);
expect(result.message).toBe('Name is required');
});
it('should validate email format', () => {
const emailInput = document.querySelector('[data-testid="email-input"]');
emailInput.value = 'not-an-email';
const result = validator.validateField('email');
expect(result.valid).toBe(false);
expect(result.message).toContain('valid email');
});
it('should show error messages in DOM', () => {
const nameInput = document.querySelector('[data-testid="name-input"]');
nameInput.value = '';
validator.validate();
const errorEl = document.querySelector('[data-testid="name-error"]');
expect(errorEl.textContent).toBe('Name is required');
expect(errorEl.classList.contains('visible')).toBe(true);
});
it('should enable submit on valid form', () => {
document.querySelector('[data-testid="name-input"]').value = 'John';
document.querySelector('[data-testid="email-input"]').value = 'john@example.com';
document.querySelector('[data-testid="password-input"]').value = 'SecurePass123!';
validator.validate();
const submitBtn = document.querySelector('[data-testid="submit-btn"]');
expect(submitBtn.disabled).toBe(false);
});
});
Async Testing
describe('ApiClient', () => {
let apiClient;
beforeEach(() => {
apiClient = new ApiClient('http://localhost:3000/api');
});
it('should fetch users successfully', async () => {
spyOn(window, 'fetch').and.returnValue(
Promise.resolve(
new Response(JSON.stringify([{ id: 1, name: 'Alice' }]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
);
const users = await apiClient.getUsers();
expect(users).toEqual([{ id: 1, name: 'Alice' }]);
expect(window.fetch).toHaveBeenCalledWith('http://localhost:3000/api/users', jasmine.any(Object));
});
it('should handle API errors', async () => {
spyOn(window, 'fetch').and.returnValue(
Promise.resolve(new Response('Not Found', { status: 404 }))
);
try {
await apiClient.getUsers();
fail('Expected an error to be thrown');
} catch (error) {
expect(error.message).toContain('404');
}
});
it('should handle network failures', async () => {
spyOn(window, 'fetch').and.returnValue(Promise.reject(new Error('Network error')));
try {
await apiClient.getUsers();
fail('Expected an error to be thrown');
} catch (error) {
expect(error.message).toBe('Network error');
}
});
});
Jasmine Spies and Mocks
describe('EventTracker', () => {
let tracker;
let mockStorage;
beforeEach(() => {
mockStorage = jasmine.createSpyObj('Storage', ['getItem', 'setItem', 'removeItem']);
tracker = new EventTracker(mockStorage);
});
it('should store events in storage', () => {
tracker.track('page_view', { page: '/home' });
expect(mockStorage.setItem).toHaveBeenCalledWith(
jasmine.stringMatching(/^event_/),
jasmine.stringContaining('"type":"page_view"')
);
});
it('should retrieve event count', () => {
mockStorage.getItem.and.returnValue(JSON.stringify({ count: 5 }));
const count = tracker.getEventCount('page_view');
expect(count).toBe(5);
});
it('should call flush callback after batch size', () => {
const flushCallback = jasmine.createSpy('flushCallback');
tracker.onFlush(flushCallback);
for (let i = 0; i < 10; i++) {
tracker.track('click', { element: `btn_${i}` });
}
expect(flushCallback).toHaveBeenCalledTimes(1);
expect(flushCallback).toHaveBeenCalledWith(jasmine.arrayContaining([
jasmine.objectContaining({ type: 'click' }),
]));
});
});
LocalStorage and SessionStorage Testing
describe('StorageService', () => {
let service;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
service = new StorageService();
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('should store and retrieve values', () => {
service.set('user', { name: 'Alice', role: 'admin' });
const result = service.get('user');
expect(result).toEqual({ name: 'Alice', role: 'admin' });
});
it('should return null for missing keys', () => {
expect(service.get('nonexistent')).toBeNull();
});
it('should handle storage quota exceeded', () => {
spyOn(localStorage, 'setItem').and.throwError('QuotaExceededError');
expect(() => service.set('key', 'value')).toThrowError('Storage quota exceeded');
});
it('should clear expired items', () => {
service.set('temp', 'data', { ttl: -1 }); // Already expired
service.cleanExpired();
expect(service.get('temp')).toBeNull();
});
});
Best Practices
- Use headless browsers in CI -- Configure
ChromeHeadlessorFirefoxHeadlessfor CI pipelines to avoid display server requirements while maintaining real browser engine testing. - Enforce coverage thresholds -- Set minimum coverage percentages in
coverageReporter.check.globaland fail the build when coverage drops below acceptable levels. - Use
singleRun: truefor CI -- Ensure Karma exits after tests complete in CI environments. Watch mode (autoWatch: true) is for development only. - Clean up DOM after each test -- Remove any elements added to
document.bodyinafterEachhooks to prevent test pollution. - Use Jasmine's
createSpyObjfor creating mock objects with multiple methods. This is cleaner than manually stubbing each method. - Configure appropriate timeouts -- Set
browserNoActivityTimeoutandbrowserDisconnectTimeouthigh enough for slow CI environments but low enough to catch hanging tests. - Use preprocessors for modern JavaScript -- Configure webpack or browserify preprocessors for TypeScript, ES modules, and JSX compilation before test execution.
- Separate CI and dev configurations -- Create
karma.ci.conf.jsthat extends the base config with CI-specific settings (headless, single run, junit reporter). - Use source maps -- Enable source map preprocessor for accurate error stack traces and coverage mapping back to original source files.
- Group related specs in
describeblocks -- Organize tests hierarchically by module, class, and method for clear reporting output.
Anti-Patterns
- Running headed browsers in CI -- Using non-headless Chrome/Firefox in CI requires a display server (Xvfb) and is slower. Always use headless variants.
- Not cleaning up DOM elements -- Elements added to
document.bodyin tests persist across tests, causing side effects and false positives. - Missing
singleRunin CI -- WithoutsingleRun: true, Karma watches for changes and never exits, hanging the CI pipeline. - Hardcoding file paths in
filesarray -- Use glob patterns (src/**/*.js) instead of listing individual files. New files are automatically included. - Not using coverage thresholds -- Without thresholds, coverage can silently drop over time. Enforce minimums to maintain quality.
- Ignoring browser-specific failures -- Tests that pass in Chrome but fail in Firefox indicate real compatibility issues. Investigate rather than skip.
- Using
fitandfdescribein committed code -- Focused tests (fit,fdescribe) skip other tests silently. Use--grepfor selective execution instead. - Not configuring preprocessors -- Serving raw TypeScript or ES modules to browsers causes syntax errors. Always configure appropriate compilation preprocessors.
- Setting
browserNoActivityTimeouttoo low -- Tests that involve async operations may need more than the default timeout. Set it to at least 30 seconds for CI. - Not using reporter plugins -- The default
progressreporter is insufficient for CI. Addjunitfor CI integration andcoveragefor quality metrics.
CLI Reference
# Run tests (uses karma.conf.js by default)
npx karma start
# Run in single-run mode
npx karma start --single-run
# Run with specific config
npx karma start karma.ci.conf.js
# Run with specific browsers
npx karma start --browsers ChromeHeadless,FirefoxHeadless
# Run specific test files
npx karma start --files "test/unit/calculator.spec.js"
# Run with verbose logging
npx karma start --log-level debug
# Initialize karma config
npx karma init
# Watch and re-run on changes
npx karma start --auto-watch --no-single-run
Setup
# Install Karma and Jasmine
npm install --save-dev karma karma-jasmine karma-chrome-launcher jasmine-core
# Coverage reporting
npm install --save-dev karma-coverage
# CI reporting
npm install --save-dev karma-junit-reporter
# TypeScript support
npm install --save-dev karma-webpack karma-sourcemap-loader typescript ts-loader
# Istanbul coverage for webpack/TypeScript
npm install --save-dev karma-coverage-istanbul-reporter
# Initialize configuration
npx karma init karma.conf.js