-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Description
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 toChangeand 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 toNotChangeusing 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
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status