Day 4

We're back! Welcome once again. This is Day 4. As usual, if you're new, you should probably start on Day 0.

At the end of each session, you get to take with you a challenge that's designed to help solidify new concepts. Last session you were tasked with making an assertion on the response to POST /login as well as making assertions on the Home page. If you were able to figure out how to circumvent the UI to authenticate, you get extra credit!

Asserting the contents of a request or its response are nearly the same.

To review, here's how to assert the request body:

cy.wait('@login')
  .its('request.body')
  .should('deep.equal', {
    username: data.username,
    password: data.password,
    remember: true,
    type: 'LOGIN'
  })

Let's look at the response.body yielded by cy.wait('@login') by selecting the command in the Command Log.

{
  "user": {
    "id": "bDjUb4ir5O",
    "uuid": "b4ebe114-141c-4397-9346-dd37d788f71b",
    "firstName": "Kaylin",
    "lastName": "Homenick",
    "username": "Allie2",
    "password": "$2a$10$5PXHGtcsckWtAprT5/JmluhR13f16BL8SIGhvAKNP.Dhxkt69FfzW",
    "email": "Rebeca35@yahoo.com",
    "phoneNumber": "072-208-4283",
    "avatar": "https://api.adorable.io/avatars/128/bDjUb4ir5O.png",
    "defaultPrivacyLevel": "private",
    "balance": 164867,
    "createdAt": "2019-09-15T04:44:05.536Z",
    "modifiedAt": "2020-05-21T18:25:10.341Z"
  }
}

Unlike the assertion for the request body, we just need to assert the username so we'll first get that and then assert that it equals the username that we set:

cy.wait('@login')
  .its('response.body.user.username')
  .should('equal', data.username)

The Take-home Challenge was different because you had to get creative about what should be tested on Home instead of just figuring out how to go about it. There's no right answer, but one way to approach is to split the interface - that is, everything we see on the / route) - into a few large parts with smaller parts within them:

  • the left navigation (side nav)
    - name, handle, avatar
    - balance
    - navigation links
    - logout
  • the top navigation (toolbar)
    - nav toggle
    - app title
    - new transaction and notification buttons
    - tabs
  • and the main content area with transactions

We could break down home.spec.js thusly:

describe('home', () => {
  describe('left navigation', () => {
    it('name, handle, avatar', () => {
      ...
    })
    ...
  })
  describe('toolbar and tabs', () => {
    it('nav toggle', () => {
      ...
    })
    ...
  })
  describe('transactions', () => {
    ...
  })
})

Within those test (it) blocks are the assertions like visibility (be.visible), text content (contain.text, have.text), attributes (have.attr), etc.


We discovered last week that signing in is required before each test. Doing so through the UI is the slowest and least reliable way to set the stage for our tests. To fix that, we first need to observe how our app authenticates.

We already know that our app makes the request, POST /login to authenticate, but how does our app persist that authentication? If we insert a cy.pause() after waiting for the request, we can start poking around.

Open Chrome DevTools and select the Application tab. Selecting our app's url, http://localhost:3000, under Cookies reveals a connect.sid cookie that wasn't there before we authenticated. We can safely assume that this is a session ID created by logging in.

We can come to the same conclusion by resuming the test and inspecting the headers in the response to POST /login, where we'll find set-cookie (which the browser uses to set the cookie for us) containing the same connect.sid value.

While we're here, let's have a look at the response body:

{
  "id": "dBjUb4ir5O",
  "uuid": "a4ebe114-141d-4397-9347-dd37d788e71b",
  "firstName": "Kaylin",
  "lastName": "Homenick",
  "username": "Allie2",
  "password": "$2a$10$5PHXGtcsckWtAprT5/JmluhR13f16BL8SGIhvAKNP.Dhxkt96FfzW",
  "email": "Rebeca35@yahoo.com",
  "phoneNumber": "072-208-4283",
  "avatar": "https://api.adorable.io/avatars/128/bDjUb4ir5O.png",
  "defaultPrivacyLevel": "private",
  "balance": 164867,
  "createdAt": "2019-09-15T04:44:05.536Z",
  "modifiedAt": "2020-05-21T18:25:10.341Z"
}

More on that in a bit...

Let's delete the cookie, click Bank Accounts and check out the Command Log:

As expected the response statuses of subsequent requests is 401 (Not Authorized)

Back to the Application tab in DevTools, select our app's url under Local Storage. There we see authState

The observant ones in the room will point out that the values of context.user and event.data.user are both equal to the response body (from POST /login) that we saw earlier.

Poking around the Real World App codebase confirms that an authentication service sets authState like this:

window.localStorage.setItem('authState', JSON.stringify(authState))

How authState plays a role here is out of the scope of this workshop but I encourage you to peruse these relevant files:

  • src/machines/authMachine.ts
  • src/components/SignInForm.tsx
  • backend/auth.ts

To summarize what we know at this point, our Real World App authenticates by making a request, POST /login with our credentials and uses the response to persist that authentication. The cookie storage is handled by the browser and the app's authentication service stores authState in Local Storage.

How do we circumvent the UI to do all this? First, with cy.request.

cy.request is used to make HTTP requests – like the ones our app is making. We simply pass an object containing method (GET, POST, PUT, DELETE, etc.), url and body. We have all these values (or know where to get them) so our request is straightforward:

cy.request({
  method: 'POST'
  url: '/login' // Cypress prepends `baseUrl` for you
  body: {
    username: data.username,
    password: data.password,
    remember: true,
    type: 'LOGIN'
  }
})

According to the Cypress docs, the cy.request command yields the response which is an object containing body. Let's use it in our spec and observe the command's console output. We should expect to see the same response when we make the request as when the app makes it, containing user in body.

We can also expect that the cookie is set, but not authState. That's next.

To access the response yielded we need .then but we only need the user object:

cy.request({
	...
})
  .its('body.user')
  .then(user => {
    // logic using `user`
})

We discovered earlier that the app sets Local Storage like this:

window.localStorage.setItem('authState', JSON.stringify(authState))

where authState contains the user object in context and event. So if we put it all together in a beforeEach hook:

beforeEach('sign in', () => {
  cy.request({
    method: 'POST',
    url: '/login',
    body: {
      type: 'LOGIN',
      username: data.username,
      password: data.password,
      remember: true
    },
  })
    .its('body.user')
    .then(user => {
      const authState = {
        value: 'authorized',
        context: { user }, // `user` is shorthand for `user: user`
        _event: {},
        event: {
          data: { user }
        }
      }
      window.localStorage.setItem('authState', JSON.stringify(authState))
    })
  cy.visit('/')
})

Let's consider Home tested for now and move on to testing My Account. Clicking on My Account from Home takes the user to a different route (/user/settings) so the feature deserves its own spec - appropriately named my-account.spec.js.

We can continue to assume the role of Product Manager or Stakeholder and make up our own requirements based on our own observations of the UI - in this case it's a form with a Save button:

[image]

It seems a logical test to start with would involve filling out the form and clicking Save.

Remember from last time, that we need to authenticate first. We could copy over the beforeEach hook, but in the typical workshop fashion (that you should be used to by now!) we're going to find a better way.

Universal programming paradigms would suggest avoiding code repetition and improve readability and maintainability through abstraction. In modern JavaScript, it means creating a module that exports a function that can be imported wherever it's needed.

In Cypress, it gets a bit more fancy with custom commands. By importing your module in cypress/support/commands.js and adding it with Cypress.Commands.add(name, callbackFn) it becomes part of the global cy namespace with all other Cypress commands.

To spice things up even more, before a spec is loaded, Cypress first loads cypress/support/index.js. This yields many benefits, the first being we can import commands.js so we don't have to import them into each spec and second being we can declare hooks - like our beforeEach hook for authenticating - that will apply to all specs.


Take-home Challenge

  1. Create a new spec, my-account.spec.js
  2. In it, create a function, signIn that takes username and password and signs in the specified user via the API and persists authentication by setting authState
  3. Call/invoke signIn from a beforeEach
  4. Write a test to update the user's email address to kaylin.homenick@gmail.com by visiting the appropriate route, filling out the form, clicking Save, and asserting the new value in the request made to the /users endpoint.
  5. Extra Credit: add the signIn function to commands.js and use Cypress.Commands.add(name, callbackFn) to create the custom command, cy.signIn, to use in your new test
  6. Extra Extra Credit: figure out a way to use cy.signIn in a global beforeEach hook, passing in the username and password set in Cypress Environment Configuration

GO TOP

🎉 You've successfully subscribed to iheartjs!
OK