Cypress Component Testing vs Angular Test Harness

A few Angular component test examples comparing Cypress and test harness.

Yesterday I have attended DevReach Boston conference. I got a chance to sit and listen to Alisa Duncan presentation "Angular Component Test Harnesses FTW". In her presentation Alisa compared Angular component tests using TestBed vs TestBed + Test harnesses. For comparison, here is the code for testing the navigation component.

Plain TestBed vs Harness plus TestBed tests

🎁 You can find these tests in the repo bahmutov/component-harness-ftw-code that I forked from alisaduncan/component-harness-ftw-code.

Ok. Even with the harness, writing component tests is not going to be easy. Yes, the interactions with the component are abstracted away a little into the harness, but it is still a lot of boilerplate code just to get the component mounted. There must be a better way. Luckily, I just came from ng-conf conference, where I taught Cypress testing workshop, including the recently released Cypress Angular Component Testing. Can we try writing Cypress Ng component tests? The existing example tests for the Navigation component test the following:

  • component mounts
1
2
3
it('should create', () => {
expect(component).toBeTruthy();
});
  • component shows Home and Log In by default
1
2
3
4
5
6
7
8
9
10
11
12
it('should show \'HOME\' and \'LOG IN\' when user is not signed in', async () => {
(Object.getOwnPropertyDescriptor(signinServiceSpy, 'isLoggedIn')?.get as jasmine.Spy).and.returnValue(false);
fixture.detectChanges();

const buttonEls = await loader.getAllHarnesses(MatButtonHarness);
expect(buttonEls).toHaveSize(2);

const buttonTexts = await parallel(() => buttonEls.map(btn => btn.getText()));

const expected = ['HOME', 'LOG IN'];
expect(buttonTexts).toEqual(expected);
});
  • component shows three buttons when logged in
1
2
3
4
5
6
7
8
9
10
11
it('should show \'HOME\', \'PROFILE\', and \'LOG OUT\' when user is signed in', async () => {
(Object.getOwnPropertyDescriptor(signinServiceSpy, 'isLoggedIn')?.get as jasmine.Spy).and.returnValue(true);
fixture.detectChanges();

const buttonEls = await loader.getAllHarnesses(MatButtonHarness);
expect(buttonEls).toHaveSize(3);
const buttonTexts = await parallel(() => buttonEls.map(btn => btn.getText()));

const expected = ['HOME', 'PROFILE', 'LOG OUT'];
expect(buttonTexts).toEqual(expected);
});

Let's do it.

Installation

The Angular project is using v14.1.0 which is automatically picked up by Cypress, so there is nothing to install. The config is pretty much the standard

cypress.config.ts
1
2
3
4
5
6
7
8
9
10
11
import { defineConfig } from "cypress";

export default defineConfig({
component: {
devServer: {
framework: "angular",
bundler: "webpack",
},
specPattern: "**/*.cy.ts",
},
});

Cypress E2E and Component Test intro screen

The first navigation test

Let's see if we can mount the component following the Cypress Angular documentation. Right next to the component we can create a spec file. We will import the component itself and use the cy.mount command to put the component onto the page.

src/app/nav/nav.component.cy.ts
1
2
3
4
5
6
7
import { NavComponent } from './nav.component'

describe('NavComponent', () => {
it('should create and show the links', () => {
cy.mount(NavComponent)
})
})

Click on the new spec file and run the test.

Cypress component test mounts the navigation component

It cannot be this easy, can it?! Let's confirm the buttons / links are visible.

1
2
3
cy.mount(NavComponent)
cy.contains('a', 'HOME')
cy.contains('button', 'LOG IN').should('be.visible')

The expected links are visible

Notice that once the component is mounted, we can use the standard Cypress commands to interact with the page. No special commands, abstractions, or adaptors needed.

Let's see if our component is working. Let's see what happens if we click on the "Log in" button. I added .wait(1000) just to make the GIF clearly show each step.

1
2
3
4
5
6
7
8
9
10
it('should create and show the links', () => {
cy.mount(NavComponent)
cy.contains('a', 'HOME')
cy.contains('button', 'LOG IN').should('be.visible').wait(1000).click()
cy.contains('a', 'HOME')
cy.contains('a', 'PROFILE').should('be.visible')
cy.contains('button', 'LOG OUT').should('be.visible').wait(1000).click()
cy.contains('a', 'HOME')
cy.contains('button', 'LOG IN').should('be.visible')
})

Our component is working!

The Nav component is working as expected

Spying on the Signin Service

Ok, the original tests also confirmed the SigninService worked. Well, they just stubbed it, so not really confirming it. We want to check if the SigninService is receiving the expected calls from the Navigation component when we click on the "Log in" and "Log out" buttons.

src/app/nav/nav.component.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
// templates and styles
export class NavComponent {

constructor(public signinService: SigninService) { }

public login(): void {
this.signinService.login();
}

public logout(): void {
this.signinService.logout();
}
}
src/app/signin.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Injectable } from '@angular/core'

@Injectable({
providedIn: 'root',
})
export class SigninService {
private isLoggedInState = false
public get isLoggedIn() {
return this.isLoggedInState
}

constructor() {}

public login(): void {
this.isLoggedInState = true
}

public logout(): void {
this.isLoggedInState = false
}
}

Let's update our test. The NavComponent gets an instance of the service via its props. The test can create an instance and pass it. The cy.mount command has options for that.

src/app/nav/nav.component.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { NavComponent } from './nav.component'
import { SigninService } from '../signin.service'

describe('NavComponent', () => {
it('should create and show the links', () => {
const signinService = new SigninService()
cy.spy(signinService, 'login').as('login')
cy.spy(signinService, 'logout').as('logout')
cy.mount(NavComponent, {
componentProperties: {
signinService,
},
})

cy.contains('a', 'HOME')
cy.contains('button', 'LOG IN').should('be.visible').wait(1000).click()
cy.get('@login').should('have.been.called')

cy.contains('a', 'HOME')
cy.contains('a', 'PROFILE').should('be.visible')
cy.contains('button', 'LOG OUT').should('be.visible').wait(1000).click()
cy.get('@logout').should('have.been.called')

cy.contains('a', 'HOME')
cy.contains('button', 'LOG IN').should('be.visible')
})
})

We can see the confirmed calls to the service in the Command Log left column.

The full NavComponent test

Ughh, this is seriously nice. Let's compare it to the original TestBed test variants.

TestBed vs Cypress component test

Email subscription component tests

Maybe the navigation component is a weird case especially suitable for Cypress component testing. Let's look at the EmailSubscriptionComponent. The test harness mounts the component and lists a series of imports.

src/app/profile/email-subscription.component.with-harness.spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
await TestBed.configureTestingModule({
declarations: [ EmailSubscriptionComponent ],
imports: [
NoopAnimationsModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule,
MatButtonModule,
MatIconModule
]
})
.compileComponents();

Ok, we can mount the component pretty much in the same way - Cypress component cy.mount for Angular is using the TestBed under the hood!

src/app/profile/email-subscription.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
describe('EmailSubscriptionComponent', () => {
it('works', () => {
cy.mount(EmailSubscriptionComponent, {
imports: [
NoopAnimationsModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule,
MatButtonModule,
MatIconModule,
],
})
})
})

We can see the component on the page. Hmm, not sure why the "+" icon is showing up as "add" but let's skip this problem for now.

Mounted EmailSubscription component

Let's check the slider: it should be disabled until we enter an email.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cy.mount(EmailSubscriptionComponent, {
imports: [
NoopAnimationsModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule,
MatButtonModule,
MatIconModule,
],
})
cy.get('input:checkbox[role=switch]').should('be.disabled')
cy.get('input[type=email]').type('[email protected]')
cy.get('input:checkbox[role=switch]').should('be.enabled')

Enter an email and the toggle element is enabled

Ok, the component also emits the entered email when the user clicks the "add" button. Does this work?

src/app/profile/email-subscription.component.ts
1
2
3
4
5
6
7
8
9
export class EmailSubscriptionComponent {
@Output() emailSubscription: EventEmitter<EmailSubscription> = new EventEmitter<EmailSubscription>();
public email: string|undefined;
public isSubscribed = true;

public onEmailSubscriptionChange() {
this.emailSubscription.emit({email: this.email, subscribe: this.isSubscribed} as EmailSubscription);
}
}

Let's see how this component is used in the application. It is used by the Profile component like this:

src/app/profile/profile.component.ts
1
2
3
<div class="my-9 bg-zinc-50 border border-slate-400 rounded-lg">
<app-email-subscription (emailSubscription)="onEmailSubscription($event)"></app-email-subscription>
</div>

Ok, let's do the same thing. Instead of mounting the component instance, we will mount a template snippet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('works', () => {
const template = `
<div class="my-9 bg-zinc-50 border border-slate-400 rounded-lg">
<app-email-subscription (emailSubscription)="onEmailSubscription($event)"></app-email-subscription>
</div>
`
cy.mount(template, {
declarations: [EmailSubscriptionComponent],
imports: [
NoopAnimationsModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule,
MatButtonModule,
MatIconModule,
],
})
cy.get('input:checkbox[role=switch]').should('be.disabled')
cy.get('input[type=email]').type('[email protected]')
cy.get('input:checkbox[role=switch]').should('be.enabled')
})

Mount template with the component

But we still need to pass a function stub as (emailSubscription)="onEmailSubscription($event)" for the component to call when the test clicks the "Add" button.

src/app/profile/email-subscription.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cy.mount(template, {
declarations: [EmailSubscriptionComponent],
imports: [
NoopAnimationsModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule,
MatButtonModule,
MatIconModule,
],
componentProperties: {
onEmailSubscription: cy.stub().as('onEmailSubscription'),
},
})
const email = '[email protected]'
cy.get('input:checkbox[role=switch]').should('be.disabled')
cy.get('input[type=email]').type(email)
cy.get('input:checkbox[role=switch]').should('be.enabled')
// needs a better selector
cy.contains('button', 'add_box').click()
cy.get('@onEmailSubscription').should('be.calledOnceWithExactly', {
email,
subscribe: true,
})

We use cy.stub to create and pass a Sinon.js stub function as a prop to the component. After clicking on the Add button we confirm that stub was called with expected arguments.

The email subscribe prop was called correctly

Let's compare this test to the original TestBed vs TestBed + test harness specs.

The three tests checking the EmailSubscription component

I know which one I like.

Add comment component test

Let's confirm the AddCommentComponent test works. First, the happy path test

src/app/shared/comment/add-comment.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { AddCommentComponent } from './add-comment.component'
import { FormsModule } from '@angular/forms'

describe('AddCommentComponent', () => {
it('submits a comment', () => {
const template = `
<app-add-comment (comment)="onCommented($event)"></app-add-comment>
`
cy.mount(template, {
declarations: [AddCommentComponent],
imports: [FormsModule],
componentProperties: {
onCommented: cy.stub().as('onCommented'),
},
})
cy.get('input[type=text]').should('have.value', '')
cy.contains('button', 'Add').should('be.disabled')
const comment = 'TEST TEST'
cy.get('input[type=text]').type(comment)
cy.contains('button', 'Add')
// cy.click only works if the button is no longer disabled
.click()
cy.get('@onCommented').should('be.calledOnceWithExactly', comment)
})
})

The comment is added

Next, the edge case - the user should not be able to add a comment with just space characters. Let's copy / paste our previous test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('does not allow empty comments', () => {
const template = `
<app-add-comment (comment)="onCommented($event)"></app-add-comment>
`
cy.mount(template, {
declarations: [AddCommentComponent],
imports: [FormsModule],
componentProperties: {
onCommented: cy.stub().as('onCommented'),
},
})
const comment = ' '
cy.get('input[type=text]').type(comment)
cy.contains('button', 'Add').click()
cy.get('@onCommented').should('be.calledOnceWithExactly', comment)
})

Hmm, the test happily passes, the component simply accepts the comments with just spaces. It does not throw any errors.

An empty comment is added

Hmm, the test harness example shows the following unhappy path test:

src/app/shared/comment/testing/add-comment-harness.spec.ts
1
2
3
4
it('should throw when an invalid comment is added', async () => {
const el = await loader.getHarness(AddCommentHarness);
await expectAsync(el.setComment(' ')).toBeRejectedWithError('Comment is invalid');
});

Where and how does it get "Comment is invalid" error? If we do a text search in the project, we find the custom component harness file

src/app/shared/comment/testing/add-comment-harness.ts
1
2
3
4
5
6
7
8
9
public async setComment(comment: string): Promise<void> {
if (comment.trim() === '') throw Error('Comment is invalid');

const input = await this._commentInput();
await input.clear();

await input.sendKeys(comment);
await input.setInputValue(comment);
}

So it is NOT the production component code checking if the comment is all blank spaces. It is our test code... Hmm. This is my argument against using PageObject in general. By adding logic here, we suddenly created a problem and unexpected behavior that is NOT part of the application. Keep it simple. If you use PageObjects, use them for simple consistent access to the page, and not for places to hide the bugs.

Ok, let's refactor our spec a little.

src/app/shared/comment/add-comment.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { AddCommentComponent } from './add-comment.component'
import { FormsModule } from '@angular/forms'

describe('AddCommentComponent', () => {
beforeEach(() => {
const template = `
<app-add-comment (comment)="onCommented($event)"></app-add-comment>
`
cy.mount(template, {
declarations: [AddCommentComponent],
imports: [FormsModule],
componentProperties: {
onCommented: cy.stub().as('onCommented'),
},
})
})

it('submits a comment', () => {
cy.get('input[type=text]').should('have.value', '')
cy.contains('button', 'Add').should('be.disabled')
const comment = 'TEST TEST'
cy.get('input[type=text]').type(comment)
cy.contains('button', 'Add')
// cy.click only works if the button is no longer disabled
.click()
cy.get('@onCommented').should('be.calledOnceWithExactly', comment)
})

it('does not allow empty comments', () => {
const comment = ' '
cy.get('input[type=text]').type(comment)
cy.contains('button', 'Add').click()
cy.get('@onCommented').should('be.calledOnceWithExactly', comment)
})
})

Let's compare the code with the test harness variant (including the custom component test harness).

Test harness tests vs Cypress component tests for AddComment component

Happy component testing!

🎓 You can find a lot of component testing examples for different frameworks from Cypress team in the repo cypress-io/cypress-component-testing-examples.