We have been using a json file src/heroes/heroes.json in the Heroes component. Our app is not talking to a backend. It would be ideal to have a fake REST API instead, and json-server can enable that. Add the following packages to our app:
yarnadd-Dconcurrentlyjson-server
Create a db.json file in the project root and copy the below content to it.
{"heroes": [ {"id":"HeroAslaug","name":"Aslaug","description":"warrior queen" }, {"id":"HeroBjorn","name":"Bjorn Ironside","description":"king of 9th century Sweden" }, {"id":"HeroIvar","name":"Ivar the Boneless","description":"commander of the Great Heathen Army" }, {"id":"HeroLagertha","name":"Lagertha the Shieldmaiden","description":"aka Hlaðgerðr" }, {"id":"HeroRagnar","name":"Ragnar Lothbrok","description":"aka Ragnar Sigurdsson" }, {"id":"HeroThora","name":"Thora Town-hart","description":"daughter of Earl Herrauðr of Götaland" } ],"villains": [ {"id":"VillainMadelyn","name":"Madelyn","description":"the cat whisperer" }, {"id":"VillainHaley","name":"Haley","description":"pen wielder" }, {"id":"VillainElla","name":"Ella","description":"fashionista" }, {"id":"VillainLandon","name":"Landon","description":"Mandalorian mauler" } ]}
Add a script to package.json near to the "start" script. This will usr the db.json file and respond with that content at localhost:4000, with a 1 second simulated network delay.
yarn start:api and browse to http://localhost:4000/heroes or http://localhost:4000/villains. We should see some data.
Update package.json scripts as below. The changes make it so that the UI server is always served together with the api server. This way the repo user can abstract away the backend needs while using the repo.
For CI, update .github/workflows/main.ymlcypress-e2e-test section > Cypress GitHub action command > start property from yarn start to yarn dev. This will ensure that the start command not only starts the UI server, but also the backend.
cypress-e2e-test:needs: [install-dependencies]runs-on:ubuntu-latestcontainer:cypress/included:10.7.0# or whatever is the lateststeps: - uses:actions/checkout@v3 - uses:bahmutov/npm-install@v1.8.21with: { useRollingCache:true } - name:Cypress e2e tests 🧪uses:cypress-io/github-action@v4.2.0with:install:false# update from yarn start to yarn devstart:yarn devwait-on:"http://localhost:3000"browser:chromeenv:GITHUB_TOKEN:${{ secrets.GITHUB_TOKEN }}
Backend-e2e
Cypress is a great tool as an API client to test any live backend; check out CRUD API testing a deployed service with Cypress. We can write backend e2e tests that ensure that the backend works properly. With json-server this is not needed, but we want to showcase a real life example and also we will use some of these api commands in the UI e2e CRUD tests to setup and clean up state. In the real world, most likely the backend would be in a separate repo and host its own Cypress api e2e tests. The common commands that would also be used in the front end would most likely be hosted in an internal Cypress test package; check out a guide about that here.
We create a backend-e2e test to show case TDD with Cypress api testing. Create a file cypress/e2e/backend/crud.cy.ts, and request a simple GET.
It would be more ideal to have the backend served on a /api route so that it is not confused by the front end, and it is more aligned with the convention. Modify the url with this change for a failing test (Red 1).
The test initially seems to work, alas rerunning it we get a 500 error, because the entity we created already exists (Red 2).
There are a few ways to handle this. We could randomize the entity, and or we could delete the entity at the end of the test. We will do those to demo best practices, however the sure proof way to deal with it is to reset the server state at the beginning of the test. We could additionally ensure that the test cleans up after itself, later.
Install json-server-reset with yarn add -D json-server-reset. Modify the package.json by appending the middleware, which enables a /reset route for our api. Any time a POST request is made to the reset route with a payload, the db.json file resets to that payload.
Now all we need is a payload, however if we import db.json to our test and also use it to reset itself, reset call will keep repeating infinitely. Make a copy of db.json to cypress/fixtures/db.json, so that we can import from there and also fully be able to stub our network later. We can modify our script to reset the data before each test as shown below (Green 2).
We can now add the rest of the test to update and delete the hero. Note that we are able use the id property in a route to get, update or delete that entity directly. Since we have a passing test, we keep adding new tests until we run into a failure. We are effectively testing json-server, therefore failures are not likely. This is the common scenario when applying a TDD-like approach after the development is done, therefore the true value of TDD is realized during development. Let's enhance the test with an update and delete (Refactor 2).
The test taking responsibility for the db state is a best practice. Here we have create update delete all under one block, but another approach could be like the below. The duplicated sub steps are highlighted in bold.
Creation
UI create
API delete
Update
API create
UI update
API delete
Delete
API create
UI delete
As you can see, covering the update scenario satisfies it all, otherwise there is duplication between the tests.
After the final delete, we should ensure that the entity is removed from the database. Add a final get to check for this (Red 3).
We will get a failure about the 404 status because Cypress retries commands (for 4 seconds) until it succeeds. If we have a check for a non-200 code, we should allow the api calls to fail.
retryOnStatusCodeFailure: Whether Cypress should automatically retry status code errors under the hood. Cypress will retry a request up to 4 times if this is set to true.
failOnStatusCode : Whether to fail on response codes other than 2xx and 3xx
We can control those two together in an argument flag allowedToFail with default value of false. When we expect to have non-200 status codes, we can set that to true. Here is the api enhancement (Green 3).
We can refactor the api to be better readable and extendible by putting the options in an object. This way, if there are multiple options, their order does not matter. If no options are passed in, it defaults to an empty object. This refactor also allows us to add new options in the future, and the api user can pick and choose which ones to utilize.
We can also improve the type by casting the cy.request response, indicating the type of the value we will get.
That is a better readable api, but there is some duplication between the CRUD functions. Consider the below function that can do any crud operation, with type safety. method and route are required. body and allowedToFail flag are optional, if they are not passed in then body is empty but allowedToFail still is false. If the body is passed in and the method is POST or PUT, the payload will be taken, otherwise undefined for GET and DELETE.
When we have ui e2e tests, we will want to use this command for setup and teardown. We can move it to a utility file, and import from there to each test that needs it. Usually the rule of the thumb is to use a utility file, and import the function from there if there are 2-3 imports. Anything beyond that is more suited from a Cypress command. Let's create a command to showcase how that can be achieved.
We kept adding tests, and got green repeatedly. Now we can keep refactoring until we are happy with the result.
Add the command crud and resetData to Cypress commands (Refactor 4).
Add the type definition of the command to ./cypress.d.ts.
// cypress.d.ts/* eslint-disable @typescript-eslint/no-explicit-any */import { MountOptions, MountReturn } from"cypress/react";importtype { Hero } from"./cypress/support/commands";export {};declare global {namespaceCypress {interfaceChainable {/** Yields elements with a data-cy attribute that matches a specified selector. * ``` * cy.getByCy('search-toggle') // where the selector is [data-cy="search-toggle"] * ``` */getByCy(qaSelector:string, args?:any):Chainable<JQuery<HTMLElement>>;/** Yields elements with data-cy attribute that partially matches a specified selector. * ``` * cy.getByCyLike('chat-button') // where the selector is [data-cy="chat-button-start-a-new-claim"] * ``` */getByCyLike( qaSelector:string, args?:any ):Chainable<JQuery<HTMLElement>>;/** Yields the element that partially matches the css class * ``` * cy.getByClassLike('StyledIconBase') // where the class is class="StyledIconBase-ea9ulj-0 lbJwfL" * ``` */getByClassLike( qaSelector:string, args?:any ):Chainable<JQuery<HTMLElement>>;/** Mounts a React node * @param component React Node to mount * @param options Additional options to pass into mount */mount( component:React.ReactNode, options?:MountOptions ):Cypress.Chainable<MountReturn>;/** * Performs crud operations GET, POST, PUT and DELETE. * * `body` and `allowedToFail are optional. * * If they are not passed in, body is empty but `allowedToFail` still is `false`. * * If the body is passed in and the method is `POST` or `PUT`, the payload will be taken, * otherwise undefined for `GET` and `DELETE`. * @param method * @param route * @param options: {body?: Hero | object; allowedToFail?: boolean} */crud( method:"GET"|"POST"|"PUT"|"DELETE", route:string, { body, allowedToFail =false, }: { body?:Hero|object; allowedToFail?:boolean } = {} ):Cypress.Chainable<Response<Hero[] &Hero>>;/** * Resets the data in the database to the initial data. */resetData():Cypress.Chainable<Response<Hero[] &Hero>>; } }}
Use the commands in the spec file. We will do a final touch up here to use faker for the data we are working with. yarn add -D @faker-js/faker (Refactor 4).
The final code may look sophisticated, but it took many cycles of getting the tests to work and refactoring to get there.
Summary
We faked a backed server for our application to talk to using json-server.
This required to seed the database with a db.json file, also to start the api server when our app starts.
package.json scripts and the CI had to be modified.
We started with a GET test to verify the seeded data. We modified the backend to use a unique prefix api, and verified heroes and villains (Red 1, Green 1, Refactor 1)
We created a new test to add a hero. But because the test leaves state behind, rerunning it caused issues (Red 2).
We used json-server-reset to reset the db to its original form before each test (Green 2).
We enhanced the test with update and delete (Refactor 2)
We added a test to ensure that the deleted entity is removed from the DB (Red 3).
To verify non-200 status codes, we improved our test api (Green 3).
We further improved the api to be better readable and extendable using an object for the optional allowedToFail (Refactor 3).
We refactored the CRUD commands into a single functions (Refactor 4).
We reached a state where we kept adding tests, and got green repeatedly.
After that point we kept refactoring until we were happy with the result, using Cypress commands, faker, better types so on and so forth.
Takeaways
The true value of TDD is realized during development. It can be applied after so, however the RedGreenRefactor cycles become more like GreenRefactor. The incremental, small steps moving forward towards more comprehensive tests and refactored code are still the same.
With Cypress the rule of the thumb is to use a utility file, and import the functions from there if there are 2-3 references / imports. Anything beyond that is more suited from a Cypress command where the function will be available in any spec without having to import. We opted to use a command to demo the more sophisticated approach that readers may not be familiar with.