Adding Cypress to an existing Angular project

Published:

Updated:

thumbnail

When I set up my Angular13 single-page application (SPA) at work, I initially considered setting up Selenium for end to end testing, but due to the steep learning curve I was experiencing with the stack in general (and some other projected hurdles such as figuring out how to mock data that would be accessed through the window.external property in the live environment), I kept putting it off.

But, in the past month I encountered a situation in which the tests that I have written for this project did not actually catch a dependency error that caused the page not to load when the template was accessed at runtime. I suspect this is because I am not using the TestBed for the majority of my unit tests, since TestBed has a high overhead and slows down my couple thousand test cases dramatically.

I also recently read some articles (particularly From Zero to Tests on Corgibytes) that got me rethinking my test structure. Most of my tests currently are simply unit tests, possibly a few integration tests - mostly, does this function do what we expect when given the inputs we expect it to get (or handle it gracefully if we give it bad inputs)? I definitely don't have any tests that address DOM rendering. So, I had a stretch of downtime where I didn't have anyone waiting on any specific projects, and figured it would be a great time to set something up.


Why Cypress? You may have noticed that I mentioned Selenium to begin with, but this article is about Cypress. Going back to Corgibytes, this time to Integration Tests Can Be Fun!, I didn't really care for the idea of setting up a black box that was hard to understand to handle my higher level tests. Plus, I've heard good things about Cypress from other sources, and it looks like it is relatively easy to set up, compared to Selenium.


Getting Started

The first thing I did after deciding to set up Cypress was hop over to their website and start watching the introduction video. This video suggests to install Cypress in any project with npm install -D cypress. I'm sure this would work, but I am also aware that Angular has its own package addition process, and sure enough, double checking for an Angular-specific install path lead me to End-to-End testing with Cypress - Testing Angular, which gives the Angular CLI command ng add @cypress/schematic instead.


NOTE: If, like me, the first thing you do after installing Cypress is run it with npm run cypress:open, you may be confused by the immediate error message of "Warning: Cypress could not verify that this server is running." Don't worry about this for now if you're following the tutorials. Later in, they go over running your development server alongside Cypress for testing.


From there I hopped into the introductory tutorials that Cypress provides to get started. This is my first experience with a browser automation suite, and I have to say, it's pretty fun to enter commands in text, hop back over to the browser window and see it executing them! Definitely had me excited to push forward and get to a state where I could test my actual application.

So, the next step was to ensure that Cypress actually could test against it. The Cypress tutorials direct the user to enter a localhost address into cy.visit(), but the Angular CLI ng add command we used earlier already set up a default localhost address that's different from the one mentioned in Cypress's tutorials... and matches the one available from ng serve. If you have customized ng serve, I don't know if this will hold true, but in my case the address Cypress was looking for is a match. (If you want to customize the localhost address, the Cypress tutorial goes over configuring this in Step 3 of Testing Your App.)

Angular also provides a basic test file with a couple of contains() assertions when you use ng add to include Cypress in your project. In my case, since this is an existing project with actual content, neither passed. However, I suspect they might be valid for the empty new app that the Angular CLI generates when creating a new application.

My case is a bit unusual in that a user should never actually see this page; users access individual pages by URLs that are stored in the software that they show up in. The only time a user would end up at the root of the application is if somehow the stored link in the software didn't match any valid route. Nonetheless, it's useful to ensure that the page loads and renders, since if it doesn't, none of the rest of the application will either! I ended up starting by just writing a few basic test cases against the minimal content and links that I have at the root of my application for proof of concept.

I also took one of the user-facing pages that was fairly simple and worked up an initial-state test for that, too, as proof of concept. I added data-cy="whatever" tags to the important elements, and had Cypress check that the ones that should exist on page load, do; and the ones that should be hidden on page load, are.

Setting up the Mock Environment

Most developers at this stage are going to be starting to test things like their login flow, and looking at building stubs for server requests. For my application, though, the software that the pages get loaded in handles all of that, my code doesn't have anything to do with authenticating a user. So what I needed to do next was figure out how to stub the entire interface that lives on window.external when the pages are live in the production environment.

What Didn't Work

First I decided to try using the custom commands feature to add a command to apply the mock I've been using for my unit tests to window.external. There was an immediate hurdle involving Typescript and parsing the commands file: as soon as I copied the example namespace declaration from the comment at the top of commands.ts and adapted it to name my custom command, I got Argument of type 'mockWindow' is not assignable to parameter of type 'keyof Chainable<any>'.ts(2345) from my linter. I tried a few things and got other errors, then ended up following along with Adding Custom Commands to Cypress Typescript. I created an index.d.ts file in ./support to hold my modified namespace declaration, and added ./support to the tsconfig.json in the cypress folder, which resolved the type errors.

// index.d.ts
declare namespace Cypress {
  interface Chainable {
    mockExternal(): Chainable<Element>
  }
}

Unfortunately, then the part of the custom command where I import the mock caused a huge error, the first part of which looked like this:

Error: Webpack Compilation Error
./node_modules/@angular/router/fesm2015/router.mjs
Module not found: Error: Can't resolve '@angular/common' in 'C:\Users\adunster\Documents\repos\HtmlApp\node_modules\@angular\router\fesm2015'
resolve '@angular/common' in 'C:\Users\adunster\Documents\repos\HtmlApp\node_modules\@angular\router\fesm2015'
  Parsed request is a module
  using description file: C:\Users\adunster\Documents\repos\HtmlApp\node_modules\@angular\router\package.json (relative path: ./fesm2015)
    Field 'browser' doesn't contain a valid alias configuration
    resolve as module
// ...

I tried getting around this by duplicating my mock to a file in cypress's directory and editing out any references that imported anything in my Angular project such as types or mocks. Only then I did I find out that nothing I assigned to a property of window.external was actually showing up in the browser.

How did I fix it?

My ultimate solution was to skip using Cypress commands entirely, and change program behavior based on environment variables. I'll probably refine this to a more specific npm script, maybe with an additional environment.ts file (as opposed the development environment in general) in the future. For now, the code that assigns the .external property in my Angular service changed from:

function _external(): any {
  return window.external
}

To:

function _external(): any {
  if (environment.production) {
    return window.external
  } else {
    return new MockCEMRWindow().external
  }
}

Now, as I develop my specific Cypress tests, I can mock data into the functions in the MockCEMRWindow.external property to produce the results I need to test. It's not ideal, but for the moment it will do what I need it to do, and actually is something I'd meant to do for a while for manual testing in a browser window running the development environment. I'm sure I will want to dig further into cy.stub() and cy.spy() when I get the chance, too.

Running the Tests Automatically

I'm sure there's a lot of room for growth in automating our deployment processes, but for now, the way building and deploying to production is currently set up is through the npm scripts in the root package.json. After completing code and testing on a feature or bug fix, the code gets merged into the fresh branch, and then we run an npm script that will cause the production server to pull the fresh branch, run the Karma/Jasmine unit tests, and then run the build script if the tests don't fail. (It's similar for the non-production server we use for semi-live testing on real data, only usually on the current development branch instead of fresh.)

Unfortunately, it looks like this will be neither simple nor straightforward to set up for this situation (especially given that all of these have to live inside a pushd command since we are using UNC paths), so this will be an adventure for another week. But Cypress has some direction here in their Continuous Integration documentation if you're looking for where to head next.

Conclusion

After getting my feet wet with Cypress and seeing a few of the things it can do, I'm actually really genuinely excited about it. It looks like a great tool and I can't wait to use it to help guarantee the resiliency of my code!

It isn't too hard to get the basics set up on an existing project, although there are a few tricks you may have to deal with depending on your environment. Their tutorials are great, though, and their documentation is clear and easy to follow.

I think if I were building a page that works through more typical API calls and HTTP requests it would be a lot easier to set up appropriate mocks and stubs, or at least have a lot more clear documentation. Still, even with the unique challenges of my environment, the basic setup has not been difficult at all. Don't be afraid to just install it and get started!

This post is tagged: