This guide shows you how to create and register custom assertions in Bupkis. You'll learn to build both simple and parameterized assertions using Zod schemas or custom functions.
Before you proceed, you should be familiar with the basic usage of Bupkis and Zod.
Generally speaking: yes.
We use custom assertions to express expectations that are specific to Our application domain or testing needs. We decree that expressiveness is unequivocally good.
Of course, these mundane reasons may also apply:
Static assertions are assertions about the way things are. They do not make expectations about behavior.
Implementing a static assertion as a Zod schema is the simplest and most robust approach:
import { createAssertion, z, use } from 'bupkis';
// Create an assertion for email validation
const emailAssertion = createAssertion(
['to be a valid email'],
z.string().email(),
);
// Register and use the assertion
const { expect } = use([emailAssertion]);
expect('user@example.com', 'to be a valid email');
This is also be called a schema-based assertion.
When Zod schemas aren't the best way forward (note: z.custom()
and .refine()
can take a lot of abuse!), you can implement the assertion logic as a function:
import { createAssertion, z, use } from 'bupkis';
const isEvenAssertion = createAssertion(
['to be even'],
(subject) => subject % 2 === 0,
);
The function receives the subject of the assertion and any parameters (if applicable; there are none here). Importantly, the function never receives phrases—that means the second parameter to the implementation function above will be undefined
and not to be even
—phrases are stripped out before the function is called.
See Allowed Return Types for Function-Style Assertions for more details.
A parametric assertion is an assertion that accepts additional parameters beyond the subject. If you need more than one chunk of data to make an assertion, then you probably want a parametric assertion. An example might be "is greater than":
expect(42, 'to be greater than', 10);
This is a parametric assertion because it needs the parameter 10
to compare against the subject 42
.
This section expects you've read Static Assertions above; especially the bit about Allowed Return Types. Just gloss over it quick; I'll wait.
Actually—nevermind; I don't care. I hate it when I read that sort of shit.
Due to their nature of needing a yet-as-of-unknown parameter, a parametric assertion implementation is always a function. In these two examples below, the function returns a Zod schema:
import { createAssertion, z, use } from 'bupkis';
// Assertion that checks if a number is greater than another
const greaterThanAssertion = createAssertion(
[z.number(), 'to be greater than', z.number()],
(_, threshold) => z.number().gt(threshold),
);
// Assertion that checks if a string contains a substring
const containsAssertion = createAssertion(
[z.string(), 'to have substring', z.string()],
(_, substring) => z.string().includes(substring),
);
// Register and use the assertions
const { expect } = use([greaterThanAssertion, containsAssertion]);
expect(10, 'to be greater than', 5);
expect('hello world', 'to have substring', 'world');
You can return a boolean
or an AssertionFailure
object just as you would with a static assertion. It's exactly the same idea:
import { createAssertion, z, use } from 'bupkis';
// boolean-style
const greaterThanAssertion = createAssertion(
[z.number(), 'to be greater than', z.number()],
(subject, threshold) => subject > threshold,
);
// AssertionFailure-style
const containsAssertion = createAssertion(
[z.number(), 'to be greater than', z.number()],
(subject, threshold) => {
if (subject <= threshold) {
return {
actual: subject,
expected: `number greater than ${threshold}`,
message: `Expected ${subject} to be greater than ${threshold}`,
};
}
},
);
See Allowed Return Types for Function-Style Assertions for more details.
You are not limited to a single parameter:
import { z, use, createAssertion } from 'bupkis';
// Assertion for number ranges
const betweenAssertion = createAssertion(
[z.number(), 'to be between', z.number(), 'and', z.number()],
// I don't recall if this is inclusive or exclusive; RTFM
(_, min, max) => z.number().min(min).max(max),
);
// Register and use the assertion
const { expect } = use([betweenAssertion]);
expect(5, 'to be between', 1, 'and', 10);
Add as many as you want. Yes… yes. Keep adding them. Goood.
Asynchronous assertions typically involve Promise
in some way.
The target use case for async assertions are high-level expectations against some async I/O operation, like an API call or database query. Here's an example—using the venerable mungodb
package—to check if a database connection function connects successfully:
import { z, use, createAsyncAssertion, FunctionSchema } from 'bupkis';
import { connect } from 'mungodb';
// Assertion that checks if the DB connected
const dbConnectedAssertion = createAsyncAssertion(
[FunctionSchema, 'to connect to the mungobase'],
async (connectFn) => {
try {
const db = await connectFn();
if (!db.isConnected()) {
return {
actual: 'Database not connected',
expected: 'Database to be connected',
message:
'Expected the database connection function to connect successfully',
};
}
} catch (err) {
return {
actual: err,
expected: 'Database to connect without error',
message:
'Expected the database connection function to connect without throwing',
};
}
},
);
const { expectAsync } = use([dbConnectedAssertion]);
await expectAsync(connect, 'to connect to the mungobase');
If your eyes are sharp, you'll have noticed the
FunctionSchema
creeping in there. What'sFunctionSchema
?Zod v4 changes how
z.function()
works; it's now intended purely for wrapping functions and automatically validating their arguments and return values. This is fine + dandy—but it isn't what we need here. We just want to say "this parameter is a function."FunctionSchema
is a simple schema that does just that. It's defined similarly to this:export const FunctionSchema = z.custom<(...args: any[]) => any>(
(value) => typeof value === 'function',
);Bupkis contains many cute helper schemas like this to correct for the various impedance mismatches Bupkis and Zod.
One powerful feature of function-based assertions is the ability to call expect from within your assertion implementation. This allows you to compose complex assertions from simpler ones, creating reusable building blocks.
You can call expect()
within your assertion function to leverage existing assertions:
import { createAssertion, z, use } from 'bupkis';
// A complex assertion that validates user objects
const validUserAssertion = createAssertion(
['to be a valid user'],
(subject, _, expect) => {
// Use existing assertions to validate parts
expect(subject, 'to be an object');
expect(subject, 'to have property', 'name');
expect(subject, 'to have property', 'email');
// Validate specific properties
expect(subject.name, 'to be a string');
expect(subject.email, 'to be a valid email'); // assumes email assertion exists
expect(subject.name, 'to have length greater than', 0);
},
);
const { expect } = use([validUserAssertion]);
expect({ name: 'Alice', email: 'alice@example.com' }, 'to be a valid user');
You can combine assertion composition with your own validation logic:
import { createAssertion, z, use } from 'bupkis';
const validProductAssertion = createAssertion(
['to be a valid product'],
(subject, _, expect) => {
// Basic structure validation
expect(subject, 'to be an object');
expect(subject, 'to have properties', ['name', 'price', 'category']);
// Type validation
expect(subject.name, 'to be a string');
expect(subject.price, 'to be a number');
expect(subject.category, 'to be a string');
// Business logic validation
if (subject.price <= 0) {
return {
actual: subject.price,
expected: 'positive number',
message: 'Product price must be positive',
};
}
// Category validation using composition
const validCategories = ['electronics', 'books', 'clothing'];
expect(subject.category, 'to be one of', validCategories);
},
);
The same pattern works with asynchronous assertions:
import { createAsyncAssertion, z, use } from 'bupkis';
const validAPIResponseAssertion = createAsyncAssertion(
['to be a valid API response'],
async (subject, _, expectAsync) => {
// Validate structure
await expectAsync(subject, 'to be an object');
await expectAsync(subject, 'to have property', 'data');
await expectAsync(subject, 'to have property', 'status');
// Validate status
await expectAsync(subject.status, 'to be a number');
await expectAsync(subject.status, 'to be between', 200, 299);
// Custom async validation
if (subject.data && typeof subject.data === 'object') {
await expectAsync(subject.data, 'to be a valid user'); // composition!
}
},
);
You can package multiple assertions into a single module for easy reuse. For example, you might create a collection of validation assertions:
// validation-assertions.ts
import { expect, z } from 'bupkis';
export const ValidationAssertions = [
expect.createAssertion(['to be a valid email'], z.string().email()),
expect.createAssertion(['to be a URL'], z.string().url()),
expect.createAssertion(['to be a UUID'], z.string().uuid()),
expect.createAssertion(['to be a valid JSON'], (subject) => {
try {
JSON.parse(subject);
return true;
} catch {
return false;
}
}),
] as const;
Then register the entire collection:
import { use } from 'bupkis';
import { ValidationAssertions } from './validation-assertions.js';
const { expect } = use(ValidationAssertions);
// Now use any of the validation assertions
expect('user@example.com', 'to be a valid email');
expect('https://example.com', 'to be a URL');
// Note that the builtin-assertions will still be available!
expect(42, 'to be a number');
You could even do something wild like publish your assertions as package for others to use.
Someone should probably go in and create assertions for all of the fancy string validation in Zod…
If a function-style assertion must indicate failure, it can:
AssertionError
(not recommended)false
to indicate failure (also not recommended)Why don't we want to throw? We don't want to encourage throwing because Bupkis will do this for you. It may also inhibit current or future functionality. It will work, but it might not be pretty.
Why don't we want to return
false
? Returningfalse
is not recommended because it provides no context about the failure. It will work, but the resultingAssertionError
will be generic and unhelpful.The above two options will work in a pinch, but you should avoid them because you might get on the naughty list.
AssertionFailure
objectAn AssertionFailure object looks like this:
type AssertionFailure = {
/**
* The actual value, or description of what actually happened
*/
actual?: unknown;
/**
* The expected value, or description of what was expected to happen
*/
expected?: unknown;
/**
* A custom error message to use
*/
message?: string;
};
If you return this object, Bupkis will stuff it into an AssertionError
and toss it. If you don't know what to put for any of the fields, just omit them.
In short, returning an AssertionFailure
object provides much more context about what went wrong.
You can return a plain-old boolean
from your assertion function. Returning true
indicates success; returning false
indicates failure. Bupkis will throw an AssertionError
for you if you return false
, but it won't be very informative.
You can return a Zod schema from your function; you do not need to call .parse()
or .safeParse()
. Bupkis will do that for you. For example, the above assertion could be written like this:
const isEvenAssertion = createAssertion(['to be even'], (_subject) =>
z.number().refine((n) => n % 2 === 0, { error: 'Expected an even number' }),
);
You'll note that _subject
is unused. Functions returning Zod schemas will generally always ignore the subject
unless there's something weird you're trying to do. More on this in Parametric Assertions later. You can even use Zod's own facilities to provide custom error messages!
If you're looking side-eyed at this, then I'll be happy to tell you that you didn't need a function at all, and could have just returned the schema:
const isEvenAssertion = createAssertion(
['to be even'],
z.number().refine((n) => n % 2 === 0, { error: 'Expected an even number' }),
);
Oh well. Lesson learned. A non-parametric assertion isn't the right use-case for it; see Parametric Assertions.
Any assertion (be it static, parametric, metaphysical, pataphysical, etc.) defined in such a way that the first item in the tuple of phrases passed to createAssertion()
is not a Zod schema is considered to have an implicit subject of type unknown
. To illustrate:
const stringAssertion = createAssertion(['to be a string'], z.string());
// Equivalent to:
const stringAssertion = createAssertion(
[z.unknown(), 'to be a string'],
z.string(),
);
We recommend supplying a specific schema for the subject; think of it as a "hint". This "hint" will provide better type inference and will warn you before you make a stupid mistake use the wrong assertion:
const numberAssertion = createAssertion(z.string(), ['to be an email'], z.string().email()));
// later
expect(42, 'to be an email'); // type error, since 42 is not a string
The point of this black magic is to reduce boilerplate. If you don't care about the type of the subject, you need an attitude adjustment you can skip it and start right in with the phrases.
If you happen to have Zod schemas laying around, you can trivially create assertions for them:
import { createAssertion, use, z } from 'bupkis';
// Schema for a user object
const UserSchema = z.object({
id: z.number().positive(),
email: z.string().email(),
name: z.string().min(1),
roles: z.array(z.enum(['admin', 'user', 'moderator'])),
});
const validUserAssertion = createAssertion(['to be a valid user'], UserSchema);
// Register and use
const { expect } = use([validUserAssertion]);
expect(
{ id: 1, email: 'john@example.com', name: 'John', roles: ['user'] },
'to be a valid user',
);
Users of language (you and I, presumably) know many different ways to write the same thing. Perhaps we can agree on this: the specific words do not matter as much as the meaning behind those words (don't you remember your semiotics?). To that end, Bupkis allows for aliases:
import { z, use, createAssertion } from 'bupkis';
// Multiple ways to express the same assertion
const stringAssertion = createAssertion(
[['to be based', 'to be bussin']],
z.string(),
);
const { expect } = use([stringAssertion]);
expect('chat', 'to be based');
expect('chat', 'to be bussin');
Remember: we are doomed to toil in this prison-house of language.
use()
before calling expect()
.For a firehose of nonsense that might be helpful, enable debug logging:
DEBUG=bupkis* npm test
Verify your assertion ID and assertion parts:
console.log('Assertion ID:', yourAssertion.id);
console.log('Phrases:', yourAssertion.parts);