systems-obscure/posts/jest-parameterization.md

4.9 KiB

title slug date tags
Jest parameterization /jest-parameterization/ 2023-11-20
learning
javascript
unit-testing

At work I am in the process of upgrading our AWS Lambdas to use the v.18 Node runtime (most of them are still using v.14). It has been a good opportunity to carry out refactoring and address technical debt.

Many of the lambdas lack unit tests whilst others have tests that haven't been maintained. Thus I've been spending time adding and optimising tests in Jest.

I've been harnessing parameterization when improving unit test coverage. The lambdas I'm currently working on are subroutines within a broader AWS Step Function that constitutes the backend of one of our internal content management systems. Much of the functionality consists in generating and parsing properties from XML and JSON. As I am testing the same code under different conditions, the tests are highly repetitive and can be readily parameterized.

Here's one example:

describe("handler", () => {
  let mockApiGatewayEvent = {
    propertyId: "1234",
    isCrossPublished: false,
  }
  describe("exit conditions", () => {
    it("should throw an error if a user attempts to unpublish an alpha file", async () => {
      event = {
        ...event,
        fileId: "alpha:1234",
      }
      await expect(handler(event)).rejects.toThrow(
        "Not allowed to unpublish file alpha:1234"
      )
    })
    it("should throw an error if user attempts to unpublish a beta file", async () => {
      event = {
        ...event,
        fileId: "beta:1234",
      }
      await expect(handler(event)).rejects.toThrow(
        "Not allowed to unpublish file beta:1234"
      )
    })
    // and so on...
  })
})

I've anonymised the specifics of the data but the process is straightforward: I'm asserting that the correct error text is returned if a user attempts to delete a certain filetype. I reduced the verbiage of countless it blocks by utilising parameterization:

describe("exit conditions", () => {
  let mockApiGatewayEvent = {
    projectId: "1234",
    preview: false,
  }

  it.each([["alpha:1234"], ["beta:1234"]])(
    "should throw an error if user attempts to unpublish %s file",
    async (fileId) => {
      mockApiGatewayEvent = {
        ...mockApiGatewayEvent,
        fileId,
      }
      await expect(handler(mockApiGatewayEvent)).rejects.toThrow(
        `Not allowed to unpublish file ${fileId}`
      )
    }
  )
})

Instead of multiple it clauses, there is a single each expression that loops through each file variant, executing the same test each time, changing only the file name that is output.

The %s symbol is a placeholder for string substitution. This lets me include the name of each variant in the test description. This is important because there is a risk of obscuring the specifics of each test iteration when using parameterization. It's essential to be able to trace a failure to the specific iteration.

In the example below, the process is functionally the same but there are more parameters in the mix:

describe("deletePageFromS3()", () => {
  beforeEach(() => {
    process.env.AWS_ENV = "live"
    s3ClientMock.reset()
  })

  const parameters = [
    {
      previouslyPublished: false,
      isDraft: false,
      bucket: "bucket:alpha",
      key: "key:alpha",
    },
    {
      previouslyPublished: true,
      isDraft: false,
      bucket: "bucket:beta",
      key: "key:beta",
    },
    {
      previouslyPublished: false,
      isDraft: true,
      bucket: "bucket:gamma",
      key: "key:gamma",
    },
    {
      previouslyPublished: true,
      isDraft: true,
      bucket: "bucket:delta",
      key: "key:delta",
    },
  ]

  it.each(parameters)(
    "should return page for deletion, given: previouslyPublished is %s, isDraft is %s",
    async ({ previouslyPublished, isDraft, bucket, key }) => {
      await deleteFileFromS3("url", previouslyPublished, isDraft)
      const deleteObjectCommand = s3ClientMock.calls()[0].args[0]
      expect(deleteObjectCommand.input).toEqual({
        Bucket: bucket,
        Key: key,
      })
    }
  )
})

The process under test is a function that uses the AWS SDK to delete objects from an S3 bucket. I am checking that the (mocked) S3 client is called with the correct parameters.

This time I am passing in an array of objects to the each function rather than a multi-dimensional array. This makes it easier to destructure the specific properties in the individual test cases. Again I use %s to interpolate a subset of the parameters into each test description. %s applies to the each value in the each array in sequence so you just repeat it to individuate the different params.

This has been a brief sketch of some applied examples of parameterization. For a better account, see Parameterized tests in JavaScript with Jest by Rafał Borowiec.