Cypress Component Testing for Vue and Angular
Cypress component testing works across frameworks. React gets the most coverage in tutorials, but Vue 3 and Angular both have first-class support. This guide covers the setup, idiomatic patterns, and real-world considerations for each.
Vue 3 Component Testing Setup
npm install --save-dev cypress
npx cypress openCypress detects Vue and generates a config. Confirm it looks like:
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
specPattern: 'src/**/*.cy.{js,ts,jsx,tsx,vue}',
supportFile: 'cypress/support/component.ts',
},
});Support file:
// cypress/support/component.ts
import { mount } from 'cypress/vue';
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);Mounting Vue 3 Components
// src/components/ProductCard.cy.ts
import ProductCard from './ProductCard.vue';
describe('ProductCard', () => {
it('renders product information', () => {
cy.mount(ProductCard, {
props: {
product: {
id: 'prod-1',
name: 'Widget Pro',
price: 29.99,
inStock: true,
},
},
});
cy.contains('Widget Pro').should('be.visible');
cy.contains('$29.99').should('be.visible');
});
});Vue component mounting accepts props, slots, global plugins, and attrs through the mount options object.
Testing Vue Composition API and Reactive State
For components using ref, computed, and watch:
<!-- src/components/QuantityPicker.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = defineProps<{
min: number;
max: number;
modelValue?: number;
}>();
const emit = defineEmits<{
'update:modelValue': [value: number];
}>();
const quantity = ref(props.modelValue ?? props.min);
const canDecrement = computed(() => quantity.value > props.min);
const canIncrement = computed(() => quantity.value < props.max);
function decrement() {
if (canDecrement.value) {
quantity.value--;
emit('update:modelValue', quantity.value);
}
}
function increment() {
if (canIncrement.value) {
quantity.value++;
emit('update:modelValue', quantity.value);
}
}
</script>// src/components/QuantityPicker.cy.ts
import QuantityPicker from './QuantityPicker.vue';
describe('QuantityPicker', () => {
it('increments and decrements within bounds', () => {
const onUpdate = cy.stub().as('onUpdate');
cy.mount(QuantityPicker, {
props: {
min: 1,
max: 5,
modelValue: 1,
'onUpdate:modelValue': onUpdate,
},
});
cy.get('[data-cy="qty-display"]').should('have.text', '1');
cy.get('[data-cy="increment"]').click();
cy.get('[data-cy="qty-display"]').should('have.text', '2');
cy.get('@onUpdate').should('have.been.calledWith', 2);
cy.get('[data-cy="decrement"]').click();
cy.get('[data-cy="qty-display"]').should('have.text', '1');
// At minimum, decrement is disabled
cy.get('[data-cy="decrement"]').should('be.disabled');
});
it('cannot exceed max', () => {
cy.mount(QuantityPicker, {
props: { min: 1, max: 3, modelValue: 3 },
});
cy.get('[data-cy="increment"]').should('be.disabled');
});
});Vue Composables and provide/inject
Testing components that use provide/inject requires passing the provided values through mount options:
// Component uses inject('cartService')
cy.mount(AddToCartButton, {
props: { productId: 'prod-42' },
global: {
provide: {
cartService: {
addItem: cy.stub().as('addItem').resolves({ success: true }),
},
},
},
});
cy.get('[data-cy="add-btn"]').click();
cy.get('@addItem').should('have.been.calledWith', 'prod-42', 1);For components using Vue Router:
import { createRouter, createMemoryHistory } from 'vue-router';
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/products/:id', component: ProductDetail },
],
});
cy.mount(ProductLink, {
props: { productId: 'prod-42' },
global: {
plugins: [router],
},
});For Pinia state:
import { createPinia, setActivePinia } from 'pinia';
import { useCartStore } from '@/stores/cart';
const pinia = createPinia();
setActivePinia(pinia);
// Pre-populate the store
const cart = useCartStore();
cart.items = [{ id: 'prod-1', quantity: 2, price: 29.99 }];
cy.mount(CartSummary, {
global: {
plugins: [pinia],
},
});
cy.get('[data-cy="cart-total"]').should('contain', '$59.98');Angular Component Testing Setup
Cypress supports Angular through its own devServer integration. For a standard Angular CLI project:
ng add @cypress/schematicThis runs the Angular Cypress schematic, which:
- Installs Cypress and configures it
- Sets up the component testing support file
- Creates
cypress.config.tswith Angular-specific settings
Manual setup:
npm install --save-dev cypress// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
specPattern: '**/*.cy.ts',
supportFile: 'cypress/support/component.ts',
},
});// cypress/support/component.ts
import { mount } from 'cypress/angular';
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);Angular Component Mounting with cy.mount()
// src/app/components/product-card/product-card.component.cy.ts
import { ProductCardComponent } from './product-card.component';
import { RouterTestingModule } from '@angular/router/testing';
import { By } from '@angular/platform-browser';
describe('ProductCardComponent', () => {
it('renders product name and price', () => {
cy.mount(ProductCardComponent, {
componentProperties: {
product: {
id: 'prod-1',
name: 'Widget Pro',
price: 29.99,
inStock: true,
},
},
imports: [RouterTestingModule],
});
cy.contains('Widget Pro').should('be.visible');
cy.contains('$29.99').should('be.visible');
});
it('emits addToCart event when button clicked', () => {
const onAddToCart = cy.stub().as('addToCart');
cy.mount(ProductCardComponent, {
componentProperties: {
product: { id: 'prod-42', name: 'Widget', price: 9.99, inStock: true },
addToCart: {
emit: onAddToCart,
} as any,
},
});
cy.get('[data-cy="add-to-cart"]').click();
cy.get('@addToCart').should('have.been.calledWith', 'prod-42');
});
});Angular Dependency Injection in Cypress Mounts
Angular's DI system is the trickiest part of component testing. Cypress's mount() for Angular accepts Angular TestBed configuration through providers and imports:
import { ProductCardComponent } from './product-card.component';
import { ProductService } from '../../services/product.service';
import { of } from 'rxjs';
describe('ProductCardComponent with service', () => {
it('loads product from service', () => {
const mockProductService = {
getProduct: cy.stub().returns(of({
id: 'prod-42',
name: 'Service Widget',
price: 19.99,
inStock: true,
})),
};
cy.mount(ProductCardComponent, {
componentProperties: {
productId: 'prod-42',
},
providers: [
{ provide: ProductService, useValue: mockProductService },
],
});
cy.contains('Service Widget').should('be.visible');
});
it('uses HttpClient with intercepted requests', () => {
cy.mount(ProductListComponent, {
imports: [HttpClientModule],
});
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{ id: '1', name: 'Widget A', price: 9.99 },
{ id: '2', name: 'Widget B', price: 14.99 },
],
});
cy.get('[data-cy="product-item"]').should('have.length', 2);
cy.contains('Widget A').should('be.visible');
});
});For reactive forms, import ReactiveFormsModule:
cy.mount(LoginFormComponent, {
imports: [ReactiveFormsModule],
});
cy.get('[data-cy="email"]').type('user@example.com');
cy.get('[data-cy="password"]').type('password123');
cy.get('[data-cy="submit"]').click();
cy.get('[data-cy="error-message"]').should('not.exist');Cypress vs @angular/core/testing (TestBed)
Angular ships with its own testing utilities: TestBed, ComponentFixture, and DebugElement. How do Cypress component tests compare?
| Aspect | Cypress component | @angular/core/testing |
|---|---|---|
| Browser | Real Chromium | jsdom (simulated) |
| Visual debugging | Time-travel, screenshots | Text output only |
| CSS rendering | Real (catches CSS bugs) | None |
| Test runner | Cypress | Jest/Karma |
| Assertion style | .should('be.visible') |
expect(el.nativeElement.textContent) |
| Setup complexity | Medium | Lower (built into Angular CLI) |
| Animation testing | Real browser animations | Requires fakeAsync + tick() |
| Existing tests | New spec format | Compatible with existing Jasmine specs |
@angular/core/testing is the Angular default for a reason — it's deeply integrated with the Angular CLI and has excellent support for Angular-specific patterns (change detection, reactive forms, DI). Most Angular teams already have thousands of tests written this way.
Cypress component tests add value for:
- Visual inspection of Angular component rendering
- Testing interactions that involve real CSS transitions
- Teams that already use Cypress for E2E and want a unified workflow
Migrating from Karma to Cypress
Angular projects created before Angular 16 used Karma + Jasmine as the default test runner. Angular 16+ switched to Jest (via ng generate @angular/core:jest), but many projects are still on Karma.
Migrate step by step, not all at once:
- Add Cypress component testing alongside Karma (they can coexist)
- Write new component tests in Cypress
- When you refactor a component, migrate its Karma tests to Cypress
- Remove Karma once the migration is complete
Keep your existing Jasmine/Karma tests running throughout. Don't delete them until you've verified equivalent Cypress coverage.
// package.json — run both test suites in CI
{
"scripts": {
"test": "ng test --watch=false",
"test:component": "cypress run --component",
"test:all": "npm run test && npm run test:component"
}
}Vue vs React vs Angular: Cypress Differences
The Cypress test syntax is identical across frameworks — cy.get(), cy.click(), cy.should() work the same everywhere. The differences are in the mount API:
| Framework | Mount import | Props | Events |
|---|---|---|---|
| React | cypress/react18 |
JSX props | JSX event props (onClick={stub}) |
| Vue 3 | cypress/vue |
{ props: {} } |
{ props: { 'onUpdate:modelValue': stub } } |
| Angular | cypress/angular |
{ componentProperties: {} } |
Stub via EventEmitter |
The underlying principle is the same: mount the component, interact with it through the DOM, assert on what the user sees. Framework differences are in configuration, not in the test style.