Sponsored Link
Looking for a unified way to  viewmonitor, and  debug Playwright Test Automation runs?
Try the Playwright Dashboard by today. Use the coupon code PWSOL10 for a 12 month 10% discount.

The Definitive Guide to API Test Automation With Playwright: Part 14 - Creating Custom Assertions Through Extending Expect

This week I'll be going back and refactoring some tests adding custom assertions to my repo. This is something I considered implementing early on but decided against it, as I wanted to keep things simple. with the release of 1.39 the Playwright team has released some easier ways to extend your test and expect fixtures through 1 fixture file. See the release notes below.

Release notes | Playwright
Version 1.39

If you are just now joining us for the first time feel free to check out the introduction post and the playwright-api-test-demo repository which in which all code examples are included.  

Why Should You Care?

In the end if what you are doing is working for you, all good you don't have to implement custom assertions in your project. But if you find yourself making the same lengthy assertion over and over again, a custom expect may come in handy.

For example the below assertions can be converted to be much simpler and easier to read.


    // I am asserting on each booking in the report array
    body.report.forEach((booking) => {
      
      //old
      expect(isValidDate(booking.start)).toBe(true);
      expect(isValidDate(booking.end)).toBe(true);
      expect(typeof booking.title).toBe("string");
      
      // new
      expect(booking.start).toBeValidDate();
      expect(booking.end).toBeValidDate();
      expect(booking.title).toBeString();
    });

With the latest 1.39 release, the ability to extend the expect via a fixture + the ability to mergeExpects and mergeTests this simplifies importing fixtures across all your tests!  Before now the custom assertions were added to the playwrightconfig.ts file. This can be seen in the below article.

Creating custom expects in Playwright: how to write your own assertions
I enjoy reading, specially now when temperatures are in the mid-80s. It’s so nice to relax by the pool with a good book. When a book is well-written, it’s easy to read, and I can spend hours turning those pages, letting my imagination create scenes and characters... The same applies

Implementing Custom Expects

So let's begin with implementing some custom expects via fixtures. We'll start with toBeValidDate(). In a previous example we created a helper that we were calling and validating that we received a true back if the date was parsable, today we'll extend the expect file to include this as a custom expect.

// lib/fixtures/toBeValidDate.ts

import { expect as baseExpect } from "@playwright/test";

export { test } from "@playwright/test";

export const expect = baseExpect.extend({
  toBeValidDate(received: any) {
    const pass = Date.parse(received) && typeof received === "string" ? true : false;
    if (pass) {
      return {
        message: () => "passed",
        pass: true,
      };
    } else {
      return {
        message: () => `toBeValidDate() assertion failed.\nYou expected '${received}' to be a valid date.\n`,
        pass: false,
      };
    }
  },
});

The logic on this custom expect is straight forward, take the received data from the expect, and validate that when using Date.parse(received) parses (and doesn't return NaN, which is a falsey value). From there we pass back the details needed when overwriting an expect.

Take note I am exporting both test and expect in this fixture in order that I have access to test when utilizing this fixture in my test.  This is a decision I made, that you don't have to make in your tests. This does allow me to only have 1 test/expect import, rather than importing test from @playwright/test.

// tests/auth/login.post.spec.ts

// If I didn't export test
import { expect } from "lib/fixtures/fixtures"; (more on fixtures below)
import { test } from "@playwright/test";

// Since I did export test I can do this
import { test, expect } from "lib/fixtures/fixtures";


test.describe("auth/login POST requests", async () => {
  ...  
  test("POST with no body", async ({ request }) => {
    const response = await request.post(`auth/login`, {});

    expect(response.status()).toBe(400);

    const body = await response.json();
    expect(body.timestamp).toBeValidDate();
    expect(body.status).toBe(400);
    expect(body.error).toBe("Bad Request");
    expect(body.path).toBe(`/auth/login`);
  });
});

I've also gone ahead and added a few more custom expects some of which won't be used in this project but could be useful for others. The below custom assertion has been used in a big way in my repo at work as we have an API with a lot of potential values depending on the input. When making assertions on a large GET items request with multiple arrays that are returned, we can create more generic assertions for multiple values.

// lib/fixtures/toBeOneOfValues.ts

import { expect as baseExpect } from "@playwright/test";

export { test } from "@playwright/test";

export const expect = baseExpect.extend({
  toBeOneOfValues(received: any, array: any[]) {
    const pass = array.includes(received);
    if (pass) {
      return {
        message: () => "passed",
        pass: true,
      };
    } else {
      return {
        message: () => `toBeOneOfValues() assertion failed.\nYou expected [${array}] to include '${received}'\n`,
        pass: false,
      };
    }
  },
});

The below custom expects makes asserting that the response is the correct type super easy!

// lib/fixtures/typesExpects.ts

import { expect as baseExpect } from "@playwright/test";

export { test } from "@playwright/test";

export const expect = baseExpect.extend({
  toBeOneOfTypes(received: any, array: string[]) {
    const pass = array.includes(typeof received) || (array.includes(null) && received == null);

    if (pass) {
      return {
        message: () => "passed",
        pass: true,
      };
    } else {
      return {
        message: () =>
          `toBeOneOfTypes() assertion failed.\nYou expected '${
            received == null ? "null" : typeof received
          }' type to be one of [${array}] types\n${
            array.includes(null)
              ? `WARNING: [${array}] array contains 'null' type which is not printed in the error\n`
              : null
          }`,
        pass: false,
      };
    }
  },

  toBeNumber(received: any) {
    const pass = typeof received == "number";
    if (pass) {
      return {
        message: () => "passed",
        pass: true,
      };
    } else {
      return {
        message: () =>
          `toBeNumber() assertion failed.\nYou expected '${received}' to be a number but it's a ${typeof received}\n`,
        pass: false,
      };
    }
  },

  toBeString(received: any) {
    const pass = typeof received == "string";
    if (pass) {
      return {
        message: () => "passed",
        pass: true,
      };
    } else {
      return {
        message: () =>
          `toBeString() assertion failed.\nYou expected '${received}' to be a string but it's a ${typeof received}\n`,
        pass: false,
      };
    }
  },

  toBeBoolean(received: any) {
    const pass = typeof received == "boolean";
    if (pass) {
      return {
        message: () => "passed",
        pass: true,
      };
    } else {
      return {
        message: () =>
          `toBeBoolean() assertion failed.\nYou expected '${received}' to be a boolean but it's a ${typeof received}\n`,
        pass: false,
      };
    }
  },

  toBeObject(received: any) {
    const pass = typeof received == "object";
    if (pass) {
      return {
        message: () => "passed",
        pass: true,
      };
    } else {
      return {
        message: () =>
          `toBeObject() assertion failed.\nYou expected '${received}' to be an object but it's a ${typeof received}\n`,
        pass: false,
      };
    }
  },
});

In the below spec you can see all the different custom expects used in one test.

// tests/test.spec.ts

import { test, expect } from "from "lib/fixtures/fixtures"; // Import the custom matchers definition

test.describe("Custom Assertions", async () => {
  test("with fixtures", async ({ request }) => {
    const response = await request.post(`auth/login`, {});

    expect(response.status()).toBe(400);

    const body = await response.json();
    expect(body.timestamp).toBeValidDate();

    const dateStr = "2021-01-01";
    expect(dateStr).toBeValidDate();

    const number = 123;
    expect(number).toBeNumber();

    const boolean = true;
    expect(boolean).toBeBoolean();

    const string = "string";
    expect(string).toBeString();

    expect(body.status).toBeOneOfValues([400, 401, 403]);
    expect(body.status).toBeOneOfTypes(["number", "null"]);
  });
});

MergeExpects Fixture

If you were paying attention in the above example you probably noticed that I only had 1 import import { test, expect } from "@fixtures/fixtures"; for all the different fixtures we've added. With the 1.39 release, the playwright team introduced an easy way to merge expect.extend and test.extend allowing you to make your imports less verbose and super clean! The Release notes can be found here.

For our example I created a fixtures.ts file with the below content. I am importing in mergeExpects() which is a new addition with the latest release, along with all the other expect.extend fixtures. I am then creating and exporting a new expect variable setting it equal to the response of mergeExpects(fixture1, fixture2, fixture3, etc). This will create a single fixture that can be imported into all my tests that use these custom assertions.

💡
If you don't have access to mergeExpects you will need to update Playwright to atleast 1.39 in your package.json file.
// lib/fixtures/fixtures.ts

import { mergeExpects } from "@playwright/test";
import { expect as toBeOneOfValuesExpect } from "lib/fixtures/toBeOneOfValues";
import { expect as toBeValidDate } from "lib/fixtures/toBeValidDate";
import { expect as typesExpects } from "lib/fixtures/typesExpects";

export { test } from "@playwright/test";

export const expect = mergeExpects(toBeOneOfValuesExpect, toBeValidDate, typesExpects);

But before we start importing the fixture, let's update our tsconfig.json and add the @fixtures relative path, and updat the previous tests and fixtures.ts file with the new imports.

// tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "esModuleInterop": true,
    "paths": {
      "@datafactory/*": ["lib/datafactory/*"],
      "@helpers/*": ["lib/helpers/*"],
      "@fixtures/*": ["lib/fixtures/*"]
    }
  }
}

Importing should look like this with the above change

// new
import { test, expect } from "@fixtures/fixtures";

// old
import { test, expect } from "lib/fixtures/fixtures";

You can see all the changes that were added in the below pull request, across the repository. I didn't do a find and replace on "@playwright/test" though I could have.

Adding custom assertions and example test by BMayhew · Pull Request #17 · playwrightsolutions/playwright-api-test-demo
Summary by CodeRabbitAlright, fam! Let’s break down these changes like a boss and craft some lit release notes for this pull request. Here’s the deal: New Feature: Added custom assertions toBeOne...

Weird Error I Experience in VSCode

I am still a bit perplexed on one error I continue to get when attempting toe use some of the custom expects. See below.

Property 'toBeValidDate' does not exist on type 'MakeMatchers<void, any, 
{ toBeOneOfValues(this: State, received: any, array: any[]): 
{ message: () => string; pass: true; } 
| { message: () => string; pass: false; }; } 
& { toBeValidDate(this: State, received: any): 
{ ...; } 
| { ...; }; } 
& { ...; }>'.ts(2339) any

I suspect this may be a bug with the Playwright codebase as it only shows this error when there is an expect(any) type, in the below example body.timestamp is type any, as it doesn't get set until the response async call is made.  If I change body.timestamp with a string of the timestamp the IDE error goes away.  If you have any ideas reach out to me on linked (see below for a link), and let me know about it!

A Little Extra: Asserting API Request Duration

I spent a good bit of time attempting to find a way to measure the duration of an API call. I initially tried creating a request fixture but could never get this to work as I could capture the duration but there was no way to pass this duration calculation to the request object to be used in the response assertions, I could however print this out out console.log within the assertion. So instead I added a duration to the test, a way to do this can be found below. It is very verbose and I don't love it, but it at least is a path forward.

// tests/auth/login.post.spec.ts

//COVERAGE_TAG: POST /auth/login

import { test, expect } from "@fixtures/fixtures";
import Env from "@helpers/env";

test.describe("auth/login POST requests", async () => {
  const username = Env.ADMIN_NAME;
  const password = Env.ADMIN_PASSWORD;

  test("POST with valid credentials", async ({ request }) => {
    // Calculating Duration
    const start = Date.now();

    const response = await request.post(`auth/login`, {
      data: {
        username: username,
        password: password,
      },
    });

    // Calculating Duration
    const end = Date.now();
    const duration = end - start;

    // Asserting Duration
    expect(duration).toBeLessThan(1000);

    expect(response.status()).toBe(200);

    const body = await response.text();
    expect(body).toBe("");
    expect(response.headers()["set-cookie"]).toContain("token=");
  });
});

Wrapping Up

The latest features in the 1.39 release should make managing imports for fixtures way easier, as we can merge fixtures to our hearts content! I believe this will have a far greater impact on extending test via fixtures than expect as this could make managing page objects through fixtures even easier.


Thanks for reading! If you found this helpful, reach out and let me know on LinkedIn or consider buying me a cup of coffee. If you want more content delivered to you in your inbox subscribe below, and be sure to leave a ❤️ to show some love.