Skip to content

Easier To Use Change Matcher (For Database Model Testing) #9403

@klondikemarlen

Description

@klondikemarlen

Clear and concise description of the problem

I want to test that my destroy service has a given side effect of deleting some child entities.
e.g.
I expect DestroyService.perform to change SomeChild.count() from 2 to 0.

The current assert.changesBy interface is pretty clunky and would look something like this (does not actually work in this form).

const counter = { count: await PositionTeam.count() }
await assert.changesBy(
  async () => {
    await DestroyService.perform(position)
    counter.count = await PositionTeam.count()
  },
  counter,
  'count',
  -2
)

In real world context, I need to test that when I remove a user from a "position" in an organization, this cleans up the "user position" associations, and then cleans up the associated workflow access list.

While it is true that I can simply test that the parent calls the child service appropriately and leave it a that, I've had better long term success with testing side effects directly (at least one level deep), and with testing deeper side effects via "feature" type tests.

Suggested solution

I propose this new syntax

await expect(
  () => DestroyService.perform(position)
).toChange(() => PositionTeam.count(), {
  from: 2,
  to: 0,
})

Via the following code

// tests/support/matchers/to-change.ts
export async function toChange<ComparatorResult = unknown>(
  operation: () => Promise<void>,
  comparator: () => Promise<ComparatorResult>,
  options?: { from: ComparatorResult; to: ComparatorResult }
) {
  if (!(operation instanceof Function)) {
    return {
      message: () => `must pass a function to expect when using toChange matcher`,
      pass: false,
    }
  }

  const initialResult = await comparator()

  // When from/to are specified, validate the initial value
  if (options !== undefined && initialResult !== options.from) {
    return {
      message: () => `expected initial result to be ${options.from}, but got ${initialResult}`,
      pass: false,
    }
  }

  await operation()

  const finalResult = await comparator()

  // When from/to are specified, check for specific change
  if (options !== undefined) {
    if (finalResult !== options.to) {
      return {
        message: () =>
          `expected final result to change from ${options.from} to ${options.to}, but changed to ${finalResult}`,
        pass: false,
      }
    }

    return {
      message: () => `expected final result not to change from ${options.from} to ${options.to}`,
      pass: true,
    }
  }

  // When no options, just check if any change occurred
  const changed = initialResult !== finalResult
  return {
    message: () =>
      changed
        ? `expected result not to change, but it changed from ${initialResult} to ${finalResult}`
        : `expected result to change from ${initialResult}, but it stayed the same`,
    pass: changed,
  }
}

export default toChange

and the inverse

// tests/support/matchers/to-not-change.ts
import { toChange } from "./to-change"

export async function toNotChange<ComparatorResult = unknown>(
  operation: () => Promise<void>,
  comparator: () => Promise<ComparatorResult>
) {
  const result = await toChange(operation, comparator)
  return {
    message: result.message,
    pass: !result.pass,
  }
}

export default toNotChange

using the following @types/vitest.d.ts

// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Assertion, AsymmetricMatchersContaining } from "vitest"

interface CustomMatchers<R = unknown> {
  toChange: <ComparatorResult = unknown>(
    comparator: () => Promise<ComparatorResult>,
    options?: { from: ComparatorResult; to: ComparatorResult }
  ) => Promise<R>
  toNotChange: <ComparatorResult = unknown>(
    comparator: () => Promise<ComparatorResult>
  ) => Promise<R>
}

declare module "vitest" {
  interface Assertion<T = unknown> extends CustomMatchers<T> {}
  interface AsymmetricMatchersContaining extends CustomMatchers {}
}

Alternative

No response

Additional context

This follows the generally beautiful syntax from Ruby's RSpec testing library.
https://rspec.info/features/3-13/rspec-expectations/built-in-matchers/change/

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    p2-nice-to-haveNot breaking anything but nice to have (priority)

    Projects

    Status

    Approved

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions