Skip to content

Matcher Architecture

expect(...).to.be(...) is built on top of a small Matcher role. The expected value passed to .be(...) is either:

  • a plain value — wrapped in the built-in BeMatcher (smartmatch), or
  • a Matcher-doing object — used directly.

This is the seam every built-in matcher and every user-defined matcher plugs into. If you want to add your own matcher without writing a class by hand, see Custom Matchersdefine-matcher produces a matcher that conforms to this same role.

The Matcher role

1
2
3
4
5
6
7
8
9
unit module BDD::Behave::Matcher;

role Matcher is export {
  method matches($actual --> Bool) { ... }
  method failure-message($actual --> Str) { Str }
  method failure-message-negated($actual --> Str) { Str }
  method expected-value(--> Mu) { Nil }
  method description(--> Str) { self.^name }
}
Method Required? Purpose
matches($actual) yes Return True / False for whether $actual matches.
failure-message($actual) no Message rendered when the expectation fails (positive form). Default: undefined Str (falls back to Expected: / to be: rendering).
failure-message-negated($actual) no Message rendered when a .not expectation fails. Default: undefined Str.
expected-value no The value stored in Failure.expected for tooling.
description no Human-readable description, useful for error reporting and reflection.

Where matcher classes live

Almost all users only ever write use BDD::Behave; — that pulls in expect, every built-in matcher, and the full DSL through the lazy-loading wrapper. Nothing here changes that.

You only need to know about specific submodules when you want to instantiate matcher classes directly — typically to compose them inside a custom matcher, or to unit-test a matcher with Test. Each family lives in its own submodule so unit tests can import just one and skip the rest:

Submodule Matcher classes
BDD::Behave::Matcher The Matcher role itself.
BDD::Behave::Matcher::Core BeMatcher, EqMatcher
BDD::Behave::Matcher::Collection IncludeMatcher, ContainExactlyMatcher, StartWithMatcher, EndWithMatcher, AllMatcher
BDD::Behave::Matcher::Type BeAMatcher, BeAnInstanceOfMatcher, RespondToMatcher, HaveAttributesMatcher
BDD::Behave::Matcher::Numeric BeGreaterThanMatcher, BeGreaterThanOrEqualMatcher, BeLessThanMatcher, BeLessThanOrEqualMatcher, BeBetweenMatcher, BeWithinMatcher
BDD::Behave::Matcher::Boolean BeTruthyMatcher, BeFalsyMatcher, BeNilMatcher
BDD::Behave::Matcher::String MatchMatcher
BDD::Behave::Matcher::Exception RaiseErrorMatcher
BDD::Behave::Matcher::Change ChangeMatcher
BDD::Behave::Matcher::Async BeKeptMatcher, BeBrokenMatcher, CompleteWithinMatcher, EmitMatcher, EmitAtLeastMatcher, CompleteMatcher, EventuallyMatcher

Each submodule already uses BDD::Behave::Matcher internally, but the role is not re-exported. If your code needs both, import both:

1
2
use BDD::Behave::Matcher;            # for `does Matcher`
use BDD::Behave::Matcher::Numeric;   # for BeBetweenMatcher, BeWithinMatcher, ...

BeMatcher (built-in)

BeMatcher wraps Raku's smartmatch operator (~~). When you write:

1
2
3
4
expect(42).to.be(42);
expect('hello').to.be(/hell/);
expect(5).to.be(1..10);
expect($x).to.be(any(1, 2, 3));

…the runner constructs BeMatcher.new(:expected(...)) under the hood. Because BeMatcher deliberately leaves failure-message undefined, failure rendering keeps the structured Expected: / to be: block plus the colorized Diff: section described in Diff Output.

Because the underlying operator is ~~, all four Raku junction kinds (any, all, one, none) compose with BeMatcher directly — see Junctions.

EqMatcher (built-in)

eq checks order-dependent structural equality using Raku's eqv operator. It's invoked via expect(...).to.eq(...):

1
2
3
4
5
expect([1, 2, 3]).to.eq([1, 2, 3]);            # passes
expect([1, 2, 3]).to.eq([3, 2, 1]);            # fails (order matters)

expect({ a => 1, b => 2 }).to.eq({ a => 1, b => 2 });  # passes
expect(42).to.eq(42);                          # passes

eqv is type-strict, so an Array is not equivalent to a List with the same elements:

1
expect([1, 2, 3]).to.eq((1, 2, 3));            # fails (Array vs List)

EqMatcher deliberately leaves failure-message undefined, so failures fall through to the structured Expected: / to be: block plus the colorized Diff: section described in Diff Output.

Negation works the usual way:

1
expect([1, 2, 3]).to.not.eq([3, 2, 1]);

ContainExactlyMatcher (built-in)

contain-exactly checks order-independent multiset equality on arrays / lists. Each item in actual must correspond to one item in the expected list (matched by eqv), with counts and totals matching:

1
2
3
4
5
expect([1, 2, 3]).to.contain-exactly(3, 1, 2);   # passes
expect([1, 1, 2]).to.contain-exactly(1, 2, 1);   # passes (multiset)
expect([1, 1, 2]).to.contain-exactly(1, 2);      # fails (counts differ)
expect([1, 2, 3]).to.contain-exactly(1, 2);      # fails (extra in actual)
expect([1, 2]).to.contain-exactly(1, 2, 3);      # fails (missing)

Items are passed as individual positional arguments. The slurp is non-flattening, so passing a single array (contain-exactly([1, 2])) looks for that array as one element. To spread an existing array, use |@arr:

1
2
my @want = 1, 2, 3;
expect([3, 2, 1]).to.contain-exactly(|@want);

The empty form passes for an empty array:

1
expect([]).to.contain-exactly();

Failure messages render as expected <actual> to contain exactly <items> (or not to contain exactly under .not).

match-array is the array-form alias — it takes a single array argument and delegates to the same matcher:

1
2
expect([1, 2, 3]).to.match-array([3, 2, 1]);   # passes
expect([1, 2, 3]).to.match-array([1, 2]);      # fails

match-array requires its argument to be an array / list; passing a scalar dies with match-array requires an array argument.

IncludeMatcher (built-in)

include checks membership across arrays, hashes, sets, bags, ranges, and strings. It's invoked via expect(...).to.include(...):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
expect([1, 2, 3]).to.include(2);              # array element
expect([1, 2, 3]).to.include(1, 3);           # multiple elements (AND)
expect([[1, 2], [3, 4]]).to.include([1, 2]);  # nested element via eqv

expect({ a => 1, b => 2 }).to.include('a');         # hash key
expect({ a => 1, b => 2 }).to.include(a => 1);      # hash key + value
expect({ a => 1, b => 2 }).to.include(:a(1));       # named-pair shorthand

expect('hello world').to.include('world');    # string substring
expect('hello world').to.include('hello', 'world');

expect(set('a', 'b')).to.include('a');        # Set / Bag membership
expect(1..10).to.include(5);                  # Range membership

Multiple items are combined with AND semantics: every item must be present for the matcher to pass. The slurp is non-flattening, so passing a single array argument (include([1, 2])) looks for that array as one element rather than spreading it. To spread an existing array, use |@arr.

Negation works the usual way:

1
expect([1, 2, 3]).to.not.include(99);

Failure messages render as expected <actual> to include <items> (or not to include under .not).

String-specific behavior

When the actual is a Str, include does substring matching via .contains. A few details worth knowing:

1
2
3
4
5
6
7
expect('').to.include('');                     # empty needle matches everything
expect("col1\tcol2").to.include("\t");         # whitespace and control chars
expect("a\nb").to.include("a\nb");             # substrings spanning newlines
expect('café au lait').to.include('café');     # Unicode (ASCII extension)
expect('日本語のテスト').to.include('日本');     # CJK
expect('done 🚀').to.include('🚀');            # emoji
expect('order #42').to.include(42);            # non-Str args are .Str-coerced

Matching is case sensitive — expect('Hello').to.include('hello') fails. For case-insensitive matching, use MatchMatcher with a regex like rx:i/hello/.

Allomorphs (IntStr, RatStr, <42>) are accepted as actuals and routed through the Str branch since they smartmatch as Str.

StartWithMatcher (built-in)

start-with checks that a sequence (Array, List) begins with the supplied items, or that a string begins with each supplied prefix:

1
2
3
4
5
6
7
8
9
expect([1, 2, 3]).to.start-with(1);            # passes
expect([1, 2, 3]).to.start-with(1, 2);         # passes (in-order prefix)
expect([1, 2, 3]).to.start-with(2);            # fails
expect([1, 2, 3]).to.start-with(2, 1);         # fails (out of order)
expect([1]).to.start-with(1, 2);               # fails (prefix longer)

expect('hello world').to.start-with('hello');         # passes
expect('hello world').to.start-with('hello', 'h');    # passes (each prefix AND)
expect('hello world').to.start-with('hello', 'world');# fails ('world' is not a prefix)

For Positional / Iterable actuals, the args form an in-order prefix matched via eqv. For Str actuals, each arg must independently be a prefix of the string (AND semantics).

The slurp is non-flattening, so passing a single array (start-with([1, 2])) looks for that array as one prefix element. To spread an existing array, use |@arr.

start-with rejects undefined or non-iterable, non-string actuals. Empty arg lists die with start-with requires at least one item.

Failure messages render as expected <actual> to start with <items> (or not to start with under .not).

String-specific behavior

When the actual is a Str, each prefix is checked via .starts-with after .Str coercion. Multi-prefix calls AND together — every supplied prefix must independently be a prefix of the string:

1
2
3
4
5
6
7
expect('').to.start-with('');                  # empty prefix matches everything
expect("\thello").to.start-with("\t");         # leading tab / control char
expect("a\nb").to.start-with("a\nb");          # prefix can cross a newline
expect('café au lait').to.start-with('café');  # Unicode (ASCII extension)
expect('日本語').to.start-with('日本');         # CJK
expect('🚀 launch').to.start-with('🚀');       # emoji
expect('42 answers').to.start-with(42);        # non-Str args are .Str-coerced

Matching is case sensitive and respects leading whitespace — expect('hello').to.start-with(' hello') fails. Allomorphs (IntStr, RatStr) are accepted as actuals and routed through the Str branch.

EndWithMatcher (built-in)

end-with mirrors start-with for the trailing end of a sequence or string:

1
2
3
4
5
6
7
8
9
expect([1, 2, 3]).to.end-with(3);              # passes
expect([1, 2, 3]).to.end-with(2, 3);           # passes (in-order suffix)
expect([1, 2, 3]).to.end-with(2);              # fails
expect([1, 2, 3]).to.end-with(3, 2);           # fails (out of order)
expect([1]).to.end-with(1, 2);                 # fails (suffix longer)

expect('hello world').to.end-with('world');           # passes
expect('hello world').to.end-with('world', 'd');      # passes (each suffix AND)
expect('hello world').to.end-with('world', 'hello');  # fails ('hello' is not a suffix)

Same slurp / type / empty-arg conventions as start-with. Failure messages render as expected <actual> to end with <items> (or not to end with under .not).

String-specific behavior

When the actual is a Str, each suffix is checked via .ends-with after .Str coercion. Multi-suffix calls AND together — every supplied suffix must independently be a suffix of the string:

1
2
3
4
5
6
7
expect('').to.end-with('');                    # empty suffix matches everything
expect("done\n").to.end-with("\n");            # trailing newline / control char
expect("a\nb").to.end-with("a\nb");            # suffix can cross a newline
expect('le café').to.end-with('café');         # Unicode (ASCII extension)
expect('日本語').to.end-with('語');             # CJK
expect('moon 🚀').to.end-with('🚀');           # emoji
expect('answer = 42').to.end-with(42);         # non-Str args are .Str-coerced

Matching is case sensitive and respects trailing whitespace — expect('hello').to.end-with('hello ') fails. Allomorphs (IntStr, RatStr) are accepted as actuals and routed through the Str branch.

AllMatcher (built-in)

all checks that every element of a collection matches an inner matcher. The inner argument is either a plain value (wrapped in BeMatcher, smartmatch semantics) or any object that does Matcher:

1
2
3
4
5
6
7
8
9
expect([1, 1, 1]).to.all(1);                # plain value via BeMatcher
expect([1, 2, 3]).to.all(Int);              # type
expect([1, 5, 10]).to.all(1..10);           # range
expect(['foo', 'food']).to.all(/^foo/);     # regex

expect([1, 2, 3]).to.all(PositiveMatcher.new);          # custom matcher
expect([[1, 2], [1, 3]]).to.all(                        # composes with built-ins
  StartWithMatcher.new(:expected([1]))
);

An empty collection passes vacuously:

1
2
expect([]).to.all(Int);                     # passes
expect(()).to.all(1);                       # passes

Undefined or non-iterable actuals fail with a shape failure message (expected ... to be a collection ...). For sequence actuals, the matcher iterates $actual.list, so Hash actuals are iterated as Pairs.

Failure messages render as expected <actual> to all <inner-description> (element at index N: <item> did not match), pointing at the first failing element. Negation renders as expected <actual> not to all <inner-description>.

Composing across collections of collections

all is most useful when the inner matcher is itself a structural matcher:

1
2
3
4
5
6
my @rows = [
  { id => 1, status => 'ok' },
  { id => 2, status => 'ok' },
];

expect(@rows).to.all(IncludeMatcher.new(:expected([status => 'ok'])));

Junctions

Pass junctions through .all(...) directly — the method binds its argument raw so junctions are not autothreaded out:

1
expect([1, 2, 3]).to.all(any(1, 2, 3));

BeAMatcher (built-in)

be-a checks whether the actual value is "of" a given type, including subclasses, composed roles, and subset types. Internally it smartmatches the actual value against the type ($actual ~~ $type):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
expect(42).to.be-a(Int);              # passes
expect(42).to.be-a(Numeric);          # passes (Int is Numeric)
expect('hi').to.be-a(Int);            # fails

class Animal {}
class Dog is Animal {}
expect(Dog.new).to.be-a(Animal);      # passes (subclass)

role Walkable { method walk { } }
class Bird does Walkable {}
expect(Bird.new).to.be-a(Walkable);   # passes (role composition)

subset Positive of Int where * > 0;
expect(5).to.be-a(Positive);          # passes
expect(-1).to.be-a(Positive);         # fails (where clause)

be-an is provided as an alias for English grammar — it behaves identically:

1
expect(42).to.be-an(Int);

Failure messages render as expected <actual> to be a <type> (or not to be a <type> under .not). The type name comes from $type.^name.

BeAnInstanceOfMatcher (built-in)

be-an-instance-of is a strict type check: the actual value's runtime type must be exactly the given type. Subclasses, composed roles, and subsets do not match. Internally it checks $actual.defined && $actual.WHAT === $type:

1
2
3
4
expect(Dog.new).to.be-an-instance-of(Dog);      # passes
expect(Dog.new).to.be-an-instance-of(Animal);   # fails (subclass)
expect(42).to.be-an-instance-of(Int);           # passes
expect(42).to.be-an-instance-of(Numeric);       # fails (parent role)

Type objects (the uninstantiated type itself) do not match:

1
expect(Int).to.be-an-instance-of(Int);          # fails (Int is undefined)

Because composed roles and subsets are not the runtime type of any concrete object, be-an-instance-of(SomeRole) and be-an-instance-of(SomeSubset) always fail. Use be-a for those cases.

Failure messages render as expected <actual> to be an instance of <type> (or not to be an instance of <type> under .not).

RespondToMatcher (built-in)

respond-to checks whether the actual value has one or more methods. Internally it uses the meta-object protocol's ^can introspection ($actual.^can($name)), so it works on both instances and type objects, and recognises methods supplied by composed roles and parent classes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Calculator {
  method add($a, $b) { $a + $b }
  method subtract($a, $b) { $a - $b }
}

expect(Calculator.new).to.respond-to('add');                # passes
expect(Calculator.new).to.respond-to('add', 'subtract');    # passes
expect(Calculator.new).to.respond-to('multiply');           # fails

role Greeter { method greet { 'hello' } }
class Person does Greeter { }
expect(Person.new).to.respond-to('greet');                  # passes (via role)

expect('hello').to.respond-to('uc', 'lc', 'chars');         # built-ins work
expect([1, 2, 3]).to.respond-to('push', 'pop');             # Arrays too

Multiple method names are AND-combined — every name must be present for the expectation to pass. When the expectation fails, the failure message lists the missing methods:

1
expected Dog.new to respond to "bark", "meow", "purr" (missing: "meow", "purr")

Negation works the usual way:

1
expect(Dog.new).to.not.respond-to('meow');

Failure messages render as expected <actual> to respond to <names> (or not to respond to <names> under .not).

HaveAttributesMatcher (built-in)

have-attributes checks several attributes of an object in one call. Each named pair maps an attribute name to an expected value (or to another Matcher). For each pair, the matcher calls the accessor on the actual value and compares — values are compared with eqv, and when the expected side is itself a Matcher its matches method is delegated to.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Person {
  has Str $.name;
  has Int $.age;
}

my $alice = Person.new(:name<Alice>, :age(30));

expect($alice).to.have-attributes(:name<Alice>, :age(30));     # passes
expect($alice).to.have-attributes(:name<Alice>);               # subset OK
expect($alice).to.have-attributes(:age(31));                   # fails

Multiple attributes are AND-combined — every pair must match. When the expectation fails, the failure message separates missing accessors (the object does not respond to the name at all) from mismatched values (accessor exists, but the value disagrees):

1
expected Person.new(name => "Alice", age => 30) to have attributes age => 31, nickname => "Al" (missing: "nickname"; mismatched: age: got 30, wanted 31)

The matcher composes naturally with other matchers — pass a Matcher instance as the expected value for any attribute:

1
2
3
4
5
6
7
use BDD::Behave::Matcher::Collection;   # StartWithMatcher
use BDD::Behave::Matcher::Type;         # BeAMatcher

expect($alice).to.have-attributes(
  :name(StartWithMatcher.new(:expected(['A']))),
  :age(BeAMatcher.new(:type(Int))),
);

Negation works the usual way:

1
expect($alice).to.not.have-attributes(:age(31));

Failure messages render as expected <actual> to have attributes <pairs> (or not to have attributes <pairs> under .not).

Comparison matchers (built-in)

Four matchers cover numeric ordering:

Matcher Operator Aliases
be-greater-than > be-gt
be-greater-than-or-equal-to >= be-gte
be-less-than < be-lt
be-less-than-or-equal-to <= be-lte
1
2
3
4
5
6
7
8
9
expect(5).to.be-greater-than(3);
expect(5).to.be-greater-than-or-equal-to(5);
expect(3).to.be-less-than(5);
expect(5).to.be-less-than-or-equal-to(5);

expect(5).to.be-gt(3);
expect(5).to.be-gte(5);
expect(3).to.be-lt(5);
expect(5).to.be-lte(5);

All four accept any Real value, so Int, Rat, and Num mix freely and negatives / zero behave as expected:

1
2
3
4
5
expect(1.5).to.be-greater-than(1.4);     # Rat vs Rat
expect(5).to.be-greater-than(4.99);      # Int vs Rat
expect(3.14e0).to.be-greater-than(3.0e0); # Num vs Num
expect(-1).to.be-greater-than(-5);
expect(0).to.not.be-greater-than(0);

Each matcher fails (rather than dying) when $actual is undefined or is not a Real, so a stray Int-typed Nil or a Str produces a recorded failure instead of a runtime error:

1
2
expect(Int).to.be-greater-than(0);  # records a failure
expect('abc').to.be-less-than(10);  # records a failure

Negation works the usual way:

1
2
expect(3).to.not.be-greater-than(5);
expect(7).to.not.be-less-than(5);

Failure messages render as expected <actual> to be greater than <expected> (and the obvious variants for the other three), or not to be … under .not.

BeBetweenMatcher (built-in)

be-between checks whether a numeric actual falls within a [min, max] range. The matcher is inclusive by default; chain .exclusive (or the explicit .inclusive) to flip the mode:

1
2
3
4
5
6
7
8
9
expect(5).to.be-between(1, 10);              # inclusive (default), passes
expect(1).to.be-between(1, 10);              # inclusive: lower bound passes
expect(10).to.be-between(1, 10);             # inclusive: upper bound passes

expect(5).to.be-between(1, 10).exclusive;    # exclusive: passes strictly inside
expect(1).to.be-between(1, 10).exclusive;    # exclusive: lower bound fails
expect(10).to.be-between(1, 10).exclusive;   # exclusive: upper bound fails

expect(5).to.be-between(1, 10).inclusive;    # equivalent to the default

All four call sites use the same chainable expectation. Re-chaining a modifier replaces any previously recorded failure, so expect(10).to.be-between(1, 10).exclusive.inclusive ends with no failure: the .exclusive step pushes one, then .inclusive clears it when the re-evaluation passes.

be-between accepts any Real actual, so Int, Rat, and Num mix freely and negative / zero bounds behave the obvious way:

1
2
3
4
expect(1.5).to.be-between(1.0, 2.0);
expect(2).to.be-between(1.5, 2.5);
expect(-3).to.be-between(-5, -1);
expect(0).to.be-between(0, 0);

The matcher fails (rather than dying) when $actual is undefined or is not a Real, so a stray Int-typed Nil or a Str produces a recorded failure instead of a runtime error:

1
2
expect(Int).to.be-between(0, 10);            # records a failure
expect('abc').to.be-between(0, 10);          # records a failure

Negation works the usual way, and composes with the inclusive / exclusive modifiers:

1
2
expect(0).to.not.be-between(1, 10);
expect(1).to.not.be-between(1, 10).exclusive;

Failure messages name both bounds and the active mode:

1
2
expected 11 to be between 1 and 10 (inclusive)
expected 10 to be between 1 and 10 (exclusive)

Failure.expected is populated as [min, max] so programmatic consumers and alternate formatters can introspect the bounds.

BeWithinMatcher (built-in)

be-within performs a tolerance check on a numeric actual against an expected target, parameterized by a delta. The call shape uses an .of(...) continuation so the delta and expected target read naturally:

1
2
3
4
expect(5.05).to.be-within(0.1).of(5.0);     # passes (|5.05 - 5.0| <= 0.1)
expect(5.1).to.be-within(0.1).of(5.0);      # passes (boundary is inclusive)
expect(5.2).to.be-within(0.1).of(5.0);      # fails
expect(3.14e0).to.be-within(0.01e0).of(3.15e0);  # Num tolerance

The boundary is inclusive — abs(actual - expected) <= delta — so a delta of 0 means actual must equal expected exactly:

1
2
expect(5).to.be-within(0).of(5);            # passes
expect(5.0001).to.be-within(0).of(5);       # fails

be-within accepts any Real actual and target, so Int, Rat, and Num mix freely. Negative values and zero behave the obvious way:

1
2
expect(-5.05).to.be-within(0.1).of(-5.0);
expect(0.05).to.be-within(0.1).of(0);

The matcher fails (rather than dying) when either $actual or the target passed to .of(...) is undefined or non-Real, so a stray Int-typed Nil or a Str produces a recorded failure instead of a runtime error:

1
2
3
expect(Int).to.be-within(0.1).of(5.0);      # records a failure
expect('abc').to.be-within(0.1).of(5.0);    # records a failure
expect(5.0).to.be-within(0.1).of(Int);      # records a failure

Negation works the usual way:

1
2
expect(5.2).to.not.be-within(0.1).of(5.0);  # passes (outside tolerance)
expect(5.05).to.not.be-within(0.1).of(5.0); # fails (inside tolerance)

Failure messages render the delta and expected target:

1
2
expected 5.2 to be within 0.1 of 5.0
expected 5.05 not to be within 0.1 of 5.0

Failure.expected is populated with the expected target (not the delta), and Failure.given holds the actual value, so programmatic consumers and alternate formatters can introspect both.

BeTruthyMatcher (built-in)

be-truthy checks Raku's boolean coercion of the actual value (?$actual). Anything that coerces to True passes; anything that coerces to False fails:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
expect(True).to.be-truthy;
expect(1).to.be-truthy;
expect('hello').to.be-truthy;
expect([1, 2, 3]).to.be-truthy;
expect({ a => 1 }).to.be-truthy;

expect(False).to.not.be-truthy;
expect(0).to.not.be-truthy;
expect('').to.not.be-truthy;
expect([]).to.not.be-truthy;
expect(Nil).to.not.be-truthy;
expect(Int).to.not.be-truthy;        # undefined type object

Raku's coercion may surprise users coming from Perl: non-empty strings are always truthy ('0'.Bool is True), but an empty Array / Hash is False:

1
2
3
expect('0').to.be-truthy;        # non-empty string in Raku
expect([]).to.not.be-truthy;
expect({}).to.not.be-truthy;

be-truthy takes no arguments. Negation works the usual way:

1
expect(0).to.not.be-truthy;

Failure messages render as expected <actual> to be truthy (or not to be truthy under .not).

BeFalsyMatcher (built-in)

be-falsy is the inverse of be-truthy — it passes when !$actual is True:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
expect(False).to.be-falsy;
expect(0).to.be-falsy;
expect('').to.be-falsy;
expect([]).to.be-falsy;
expect({}).to.be-falsy;
expect(Nil).to.be-falsy;
expect(Int).to.be-falsy;            # undefined type object

expect(True).to.not.be-falsy;
expect(1).to.not.be-falsy;
expect('hello').to.not.be-falsy;
expect([1, 2, 3]).to.not.be-falsy;

be-falsy takes no arguments. Failure messages render as expected <actual> to be falsy (or not to be falsy under .not).

BeNilMatcher (built-in)

be-nil passes when the actual value is undefined (!$actual.defined). That covers Raku's three flavors of "not a value":

  • Nil itself
  • Any (the default for an unassigned scalar)
  • any type object, including built-ins like Int/Str and user-defined classes
1
2
3
4
5
6
7
8
expect(Nil).to.be-nil;
expect(Any).to.be-nil;
expect(Int).to.be-nil;          # undefined built-in type object
expect(Str).to.be-nil;

my class Widget {}
expect(Widget).to.be-nil;       # user-defined type object
expect(Widget.new).to.not.be-nil; # defined instance

Defined values — even "empty" or falsy ones — fail be-nil:

1
2
3
4
5
expect(0).to.not.be-nil;
expect('').to.not.be-nil;
expect([]).to.not.be-nil;
expect({}).to.not.be-nil;
expect(False).to.not.be-nil;

This is the type-object-vs-instance distinction that Raku makes explicit: Int (the type) is undefined, but 0 (an instance of Int) is defined. Use be-nil when you care about "is this a real value", and be-falsy when you care about boolean coercion (which treats empty collections as false too).

Note that assigning Nil to a plain my $x reverts $x to Any — both still pass be-nil, so the distinction rarely matters in practice.

be-nil takes no arguments. Failure messages render as expected <actual.raku> to be nil (or not to be nil under .not).

MatchMatcher (built-in)

match passes when a Str actual value smartmatches against a Regex expected value ($actual ~~ /pattern/). It accepts the full Raku regex syntax, including character classes, alternation, anchors, modifiers, and rx//-quoted forms.

1
2
3
4
expect('abc123').to.match(/\d+/);
expect('hello world').to.match(/world/);
expect('HELLO').to.match(rx:i/hello/);     # case-insensitive
expect('hello').to.match(/^hello$/);       # anchored

Negation uses the same .not chain:

1
2
expect('abc').to.not.match(/\d+/);
expect('cat').to.not.match(/dog/);

Undefined and non-Str actuals fail rather than throw — expect(Any), expect(Str), expect(42), and expect([1, 2, 3]) all record a failure when matched against any regex, so a stray nil or wrongly typed value produces a normal expectation failure instead of a runtime exception.

Failure messages render the actual value and the regex via .raku:

1
2
expected "abc" to match /\d+/
expected "abc123" not to match /\d+/

Failure.given carries the original string and Failure.expected carries the Regex itself, so programmatic consumers and alternate formatters can inspect either side.

match is regex-only; for substring checks use include (see Matchers › IncludeMatcher).

RaiseErrorMatcher (built-in)

raise-error passes when the actual value is a Callable and invoking it raises an exception. Because the matcher operates on a Callable, the block must be passed unevaluated — wrap the code under test in { ... }:

1
2
3
expect({ die "boom" }).to.raise-error;
expect({ X::AdHoc.new(payload => 'x').throw }).to.raise-error;
expect({ 1 + 1 }).to.not.raise-error;

Filtering by exception type

Pass an exception type as the first argument to require that the raised exception is that type, a subclass of it, or a class that does it as a role (matching uses smartmatch, i.e. $exception ~~ $type):

1
2
3
4
5
6
class X::Demo is Exception { method message { 'demo' } }
class X::Sub  is X::Demo  { }

expect({ X::Demo.new.throw }).to.raise-error(X::Demo);   # passes
expect({ X::Sub.new.throw  }).to.raise-error(X::Demo);   # passes (subclass)
expect({ die "boom"        }).to.raise-error(X::Demo);   # fails

When the block raises a different type, the failure surfaces what actually happened:

1
expected block to raise X::Demo, but raised X::AdHoc: boom

When the block does not raise at all under a typed expectation:

1
expected block to raise X::Demo, but none was raised

Failure.expected carries the expected type, so programmatic consumers and alternate formatters can read it directly.

Filtering by message pattern

Pass a Regex (with or without a leading type) to require that the raised exception's .message matches the regex:

1
2
3
expect({ die "code=42"            }).to.raise-error(/'code=42'/);
expect({ X::Demo.new.throw        }).to.raise-error(X::Demo, /demo/);
expect({ X::Demo.new.throw        }).to.raise-error(X::Demo, /nope/);  # fails

When the type matches but the message does not (or vice versa), the failure says what was raised so the mismatch is diagnosable inline:

1
2
expected block to raise X::Demo with message matching /nope/,
  but raised X::Demo: demo

Filtering by message via with-message

raise-error also supports a chained .with-message(...) form that filters by either an exact Str or a Regex. The chain is equivalent to passing the regex inline but reads more naturally when the message constraint is a string:

1
2
3
expect({ die "boom" }).to.raise-error.with-message('boom');         # exact
expect({ die "code=42" }).to.raise-error.with-message(/'code=42'/); # regex
expect({ X::Demo.new.throw }).to.raise-error(X::Demo).with-message('demo');

Str arguments compare with eq (the full message must match exactly); Regex arguments compare with ~~. The chain may be re-applied — only the most recent call's expectation is in effect, and any failure recorded by an earlier link in the chain is replaced rather than appended:

1
2
3
4
5
my $exp = expect({ die "boom" }).to.raise-error.with-message('bang');
# Failures.list has one entry.
$exp.with-message('boom');
# Failures.list is now empty — the second call succeeded and cleared
# the prior failure.

When the chain ultimately fails, the failure message reflects the final constraint:

1
2
expected block to raise an error with message "bang", but raised X::AdHoc: boom
expected block to raise X::Demo with message "demo", but raised X::AdHoc: oops

.with-message also composes with .not:

1
2
expect({ die "boom" }).to.not.raise-error.with-message('bang');  # passes
expect({ die "boom" }).to.not.raise-error.with-message('boom');  # fails

Negation

.not.raise-error(...) passes whenever the block fails to match the full filter — i.e. nothing was raised, or a different type was raised, or the message did not match. It only fails when the block raised exactly the forbidden exception:

1
2
3
expect({ die "boom" }).to.not.raise-error(X::Demo);   # passes (other type)
expect({ 1 + 1 }).to.not.raise-error(X::Demo);        # passes (no raise)
expect({ X::Demo.new.throw }).to.not.raise-error(X::Demo);   # fails

When .not.raise-error fails, the negated failure includes the raised exception's type and message so you can diagnose without re-running:

1
2
expected block not to raise an error, but one was raised (X::AdHoc: boom)
expected block not to raise X::Demo, but one was raised (X::Demo: demo)

Non-Callable actuals

Non-Callable actuals fail rather than throw — expect(42).to.raise-error records a normal expectation failure rather than blowing up. The failure message names the unwrapped value so the mistake is obvious:

1
expected a Callable for raise-error, but got 42

Failure metadata

Failure.given carries the original Callable. Failure.expected carries the expected exception type (when one was supplied), or the expected message (regex or Str, whichever was supplied via the inline form or with-message); for the bare no-argument form it remains unset. The matcher itself also exposes raised-exception (the captured throw) and miss-reason (one of 'none', 'type', 'message', 'non-callable', or Str on a pass) for tooling that needs structural access.

ChangeMatcher (built-in)

change passes when invoking the actual block changes the value returned by an observable block. Both the action and the observable are passed unevaluated as { ... } blocks — the matcher invokes the observable, runs the action, then invokes the observable again and compares the two values via eqv:

1
2
3
4
5
6
7
8
my $counter = 0;
expect({ $counter++ }).to.change({ $counter });        # passes

my @items;
expect({ @items.push: 'x' }).to.change({ @items.elems });  # passes

my $counter2 = 5;
expect({ 1 + 1 }).to.change({ $counter2 });            # fails (unchanged)

The observable can be any expression — a variable, a method call, a derived value — as long as the block returns something comparable. The matcher uses eqv for the before / after comparison, so deep structural equality is respected: mutating an array element to the same value (@items[0] = 1 when it was already 1) does not register as a change.

Capturing snapshots

Because eqv compares the values returned by the observable each time, an observable like { @items } returns the same container on both calls. To detect element-level mutation, snapshot the value:

1
2
3
my @items = 1, 2, 3;
expect({ @items.push: 4 }).to.change({ @items.clone });   # passes
expect({ @items.push: 5 }).to.change({ @items.elems });   # passes (count)

Negation

.not.change passes when the action does not change the observable, and fails when it does:

1
2
3
4
5
my $counter = 5;
expect({ 1 + 1 }).to.not.change({ $counter });        # passes

expect({ $counter = 9 }).to.not.change({ $counter });
# fails: expected block not to change observable, but it changed from 5 to 9

Non-Callable actuals

Non-Callable actuals fail rather than throw — expect(42).to.change({ $x }) records a normal expectation failure rather than blowing up. The failure message names the unwrapped value:

1
expected a Callable for change, but got 42

Filtering with .from and .to

change returns a chainable expectation that can be narrowed with .from(value) and .to(value) to assert the precise transition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
my $counter = 0;
expect({ $counter = 10 }).to.change({ $counter }).from(0).to(10);

# .from alone — must start at 0 *and* change
my $a = 0;
expect({ $a++ }).to.change({ $a }).from(0);

# .to alone — must end at 10 *and* differ from the start
my $b = 0;
expect({ $b = 10 }).to.change({ $b }).to(10);

# .to.from order is interchangeable
my $c = 1;
expect({ $c = 2 }).to.change({ $c }).to(2).from(1);

.from and .to use eqv for the comparison, so deep structural equality is respected (e.g. .from([]) matches an empty-array snapshot). The action block is run exactly once per .change(...) call — chaining .from(...).to(...) after a .change(...) does not re-run it.

When a chain step fails and a later step passes, the prior failure is replaced (rather than duplicated) so the recorded Failure list always reflects the final state.

Negation with .from / .to

The chain participates in the existing .not flow with conjunction semantics: .not.change(...).from(x).to(y) passes when the joint constraint (change AND from match AND to match) does not hold. This means the block may still have changed the observable — just not in that particular direction. The negated failure message includes the clause for clarity:

1
2
3
4
5
6
7
my $counter = 0;
expect({ $counter = 7 }).to.not.change({ $counter }).from(0).to(10);
# passes — the value changed, but not to 10

expect({ $counter = 10 }).to.not.change({ $counter }).from(0).to(10);
# fails: expected block not to change observable from 0 to 10,
#        but it changed from 0 to 10

Filtering with .by / .by-at-least / .by-at-most

For numeric observables, .by(delta) asserts the precise signed change, while .by-at-least(delta) and .by-at-most(delta) bound the change from below and above:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
my $counter = 0;
expect({ $counter += 5 }).to.change({ $counter }).by(5);

# negative delta
my $balance = 100;
expect({ $balance -= 25 }).to.change({ $balance }).by(-25);

# bounded range
my $score = 0;
expect({ $score += 5 }).to.change({ $score })
  .by-at-least(1).by-at-most(10);

All three accept any Real (Int, Rat, Num); the matcher subtracts after - before and compares against the expected delta with the usual numeric operators. If the observable returns a non-numeric value (e.g. a Str), the matcher records a by-non-numeric failure rather than coercing.

The by-modifiers compose with .from(...) and .to(...). From and to mismatches still take priority — .from(0).by(5) reports a from-mismatch when the value did not start at 0, even if the delta would otherwise be wrong. RSpec parity: when the value did not change at all, the no-change miss-reason fires first, so .by(0) is effectively unreachable.

When a chain step fails and a later step passes, the prior failure is replaced (rather than duplicated) so the recorded Failure list always reflects the final state.

Failure messages

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
expected block to change observable, but it remained 7
expected block not to change observable, but it changed from 0 to 9
expected block to change observable from 0, but it started as 3
expected block to change observable to 10, but it ended as 7
expected block to change observable from 0 to 10, but it started as 3
expected block to change observable from 0 to 10, but it ended as 7
expected block to change observable from 5 to 5, but it remained 5
expected block to change observable by 5, but it changed by 3
expected block to change observable by at least 5, but it changed by 3
expected block to change observable by at most 5, but it changed by 7
expected a Callable for change, but got 42

Failure metadata

Failure.given carries the original action Callable. Failure.expected reflects the most-specific constraint supplied: [from, to] when both endpoints are set, the from / to value alone when only one is set, the by / by-at-least / by-at-most value when only a delta constraint is set, or Nil for the bare form. The matcher itself exposes before-value, after-value, action-ran, callable-given, and miss-reason (one of 'non-callable', 'from', 'to', 'no-change', 'by', 'by-at-least', 'by-at-most', 'by-non-numeric', or Str on a pass) for tooling that needs structural access to the captured states. delta returns after - before when both values are Real, and Nil otherwise.

BeKeptMatcher (built-in)

be-kept waits for a Promise to settle and passes when it ends in the Kept state. It blocks up to a configurable timeout (default 5 seconds) using Promise.anyof(...) so it never hangs forever:

1
2
3
4
expect(Promise.kept('done')).to.be-kept;          # passes immediately
expect(start { compute() }).to.be-kept;           # passes when start { } finishes
expect(Promise.broken('oops')).to.not.be-kept;    # passes; broken is not kept
expect($pending).to.be-kept(0.5);                 # custom timeout in seconds

If the promise broke, the failure message includes the cause's class and message so you can see what went wrong without rerunning:

1
expected Promise to be kept, but it was broken (X::AdHoc: boom)

If the timeout elapsed with the promise still pending, the message quotes the timeout:

1
expected Promise to be kept within 0.05s, but it was still pending

The matcher exposes value (set when the promise was kept), cause (set when the promise was broken), timed-out, and promise-given for tooling.

be-kept accepts a positional Real timeout in seconds; pass 0.5 for 500 ms. Non-Promise actuals fail with a Promise-shape message (expected a Promise for be-kept, but got 42) rather than dying.

BeBrokenMatcher (built-in)

be-broken is the mirror of be-kept: it waits up to the timeout (default 5 seconds) and passes when the promise ends in the Broken state. The captured cause is exposed on the matcher's cause accessor:

1
2
3
4
expect(Promise.broken('boom')).to.be-broken;
expect(start { die 'eventual failure' }).to.be-broken;
expect(Promise.kept('happy')).to.not.be-broken;
expect($pending).to.be-broken(0.5);               # custom timeout

When the promise was kept instead, the failure message includes the kept value:

1
expected Promise to be broken, but it was kept with "happy"

When the promise was still pending after the timeout:

1
expected Promise to be broken within 0.05s, but it was still pending

The negated form surfaces the broken cause so you can see what broke when you expected it not to:

1
expected Promise not to be broken, but it was (X::AdHoc: boom)

The matcher exposes value (set when the promise was kept), cause (set when broken), was-kept, timed-out, and promise-given.

CompleteWithinMatcher (built-in)

complete-within passes when the promise settles — either kept or broken — within the given duration. The duration is a Real positional argument in seconds:

1
2
3
4
5
expect(Promise.kept('done')).to.complete-within(1);
expect(Promise.broken('boom')).to.complete-within(1);
expect(start { sleep 0.01; 42 }).to.complete-within(1);

expect($pending).to.not.complete-within(0.05);

The failure message when the promise is still pending:

1
expected Promise to complete within 0.05s, but it was still pending

The negated form indicates the final status:

1
2
expected Promise not to complete within 1s, but it was kept with "done"
expected Promise not to complete within 1s, but it was broken (X::AdHoc: boom)

Failure.expected carries the duration so failure metadata is queryable from tooling. The matcher exposes final-status (Kept, Broken, or Nil when timed out), value, cause, timed-out, and promise-given.

Unlike be-kept / be-broken, the duration is required — there's no useful default for "how long is too long" without it.

EmitMatcher (built-in)

emit taps a Supply or iterates a Channel and passes when the source emits exactly the expected sequence of values within a configurable collection window. Comparison is element-by-element via eqv:

1
2
3
4
5
6
7
8
expect(Supply.from-list(1, 2, 3)).to.emit(1, 2, 3);
expect(Supply.from-list('a', 'b')).to.emit('a', 'b');

my $ch = Channel.new;
$ch.send(1); $ch.send(2); $ch.close;
expect($ch).to.emit(1, 2);

expect(Supply.from-list(1, 2)).to.not.emit(1, 2, 3);

The collection window defaults to 1 second. Pass :within(seconds) to change it — useful for slow streams or to fail fast on hung supplies:

1
expect(Supply.from-list(1, 2)).to.emit(1, 2, :within(0.5));

emit uses react/whenever under the hood and stops collecting as soon as the expected number of values arrive (the window is only the maximum wait). This makes the matcher fast for known-length sequences.

Failure messages render both the expected and the actually-emitted sequences:

1
expected stream to emit [1, 2, 3] within 1s, but it emitted [1, 2]

Non-stream actuals (anything that's neither Supply nor Channel) record a shape-failure rather than dying:

1
expected a Supply or Channel for emit, but got 42

Failure.expected carries the expected values list for tooling.

EmitAtLeastMatcher (built-in)

emit-at-least passes when a Supply or Channel emits at least the given number of values within the collection window. The matcher stops collecting as soon as the threshold is reached:

1
2
expect(Supply.from-list(1, 2, 3, 4)).to.emit-at-least(2);
expect(Supply.from-list('a')).to.not.emit-at-least(3, :within(0.1));

The :within window defaults to 1 second.

Failure messages report the count that was actually emitted:

1
expected stream to emit at least 3 values within 0.1s, but it emitted 1

Failure.expected carries the minimum count.

CompleteMatcher (built-in)

complete waits for a Supply to send done (or for a Channel to be closed) within the collection window:

1
2
3
4
5
6
7
8
expect(Supply.from-list(1, 2, 3)).to.complete;

my $ch = Channel.new;
$ch.send(1); $ch.close;
expect($ch).to.complete;

my $supplier = Supplier.new;
expect($supplier.Supply).to.not.complete(:within(0.1));

The :within window defaults to 1 second.

Failure messages report whether the stream was still active or had errored:

1
2
expected stream to complete within 0.1s, but it was still active (emitted 0 values)
expected stream to complete within 1s, but it quit (X::AdHoc: boom)

Failure.expected carries the window so failure metadata is queryable from tooling.

Unlike Promise's complete-within, the complete matcher does not treat a stream-level error (QUIT) as completion — only the done signal counts.

EventuallyMatcher (built-in)

eventually re-runs a Callable block on a polling loop until the inner matcher passes or the timeout elapses. Useful for asserting on eventually-consistent state without hand-rolled polling:

1
2
3
expect({ get-job-status() }).to.eventually.be('done');
expect({ counter() }).to.eventually(:timeout(5)).be-greater-than(100);
expect({ log-buffer() }).to.eventually.match(/'ready'/);

The chained matcher methods (.be, .eq, .match, .include, .be-truthy, .be-a, .be-greater-than, etc.) build the appropriate inner matcher and feed each polled value through it. For matchers outside the built-in chained set, pass a Matcher instance through .matches-with:

1
expect({ load() }).to.eventually.matches-with(MyCustomMatcher.new(...));

The eventually(...) call takes two named arguments:

Name Default Meaning
:timeout 2.0 Max seconds to wait for a match
:interval 0.05 Seconds to sleep between iterations

Polling exits as soon as the inner matcher passes, so a passing assertion completes quickly. A miss runs out the full timeout before recording a failure.

Failure messages report the iteration count and the elapsed wall-clock time:

1
eventually: Expected: 99 to be 0 (after 5 iterations in 0.05s)

If the block throws on every iteration, the failure message names the last exception:

1
eventually: block threw X::AdHoc: not yet (after 5 iterations in 0.05s)

Exceptions raised inside the block are caught per-iteration and treated as misses — the polling loop continues so the block has a chance to recover (e.g., a service warming up).

Negation

Under .not, eventually passes only when the inner matcher never matched for the full timeout window:

1
expect({ get-status() }).to.not.eventually(:timeout(0.1)).be('error');

Because polling exits at the first match, a negated assertion that matches early fails quickly rather than waiting out the timeout.

Failure metadata

  • Failure.given is the Callable block (preserved as-is)
  • Failure.expected is the inner matcher's expected-value
  • Failure.message includes iteration count and elapsed time

Non-Callable actuals

expect(42).to.eventually.be(42) records a Promise-shape failure because the actual is not a Callable. The block form is required.

Writing a custom matcher

Define a class that does Matcher and pass an instance to .be(...):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use BDD::Behave;
use BDD::Behave::Matcher;

class EvenMatcher does Matcher {
  method matches($actual --> Bool) { ?($actual %% 2) }
  method failure-message($actual --> Str) {
    "expected $actual to be even";
  }
  method failure-message-negated($actual --> Str) {
    "expected $actual not to be even";
  }
  method expected-value(--> Mu) { 'an even number' }
}

it 'is even', {
  expect(4).to.be(EvenMatcher.new);          # passes
  expect(5).to.not.be(EvenMatcher.new);      # passes
}

When the matcher reports a failure:

  • The matcher's failure-message($actual) (or failure-message-negated($actual) for .not) becomes Failure.message and is what the failure summary prints.
  • Failure.given holds the actual value, Failure.expected holds expected-value. Both are still useful for programmatic consumers and for alternate formatters.

Negation

.not flips the boolean result of matches before the framework decides whether to record a failure. Matchers do not need to special-case negation; they only need to return a sensible message from failure-message-negated for when .not fails (i.e., the matcher matched but should not have).

Built-in matchers and user-supplied matchers go through the same role, so a custom matcher you write integrates with expect exactly the way the built-ins do.

For the DSL-form alternative — defining matchers as a bundle of callbacks without writing a class — see Custom Matchers.

Composing matchers

Every type that does Matcher automatically gets .and and .or methods that build AndMatcher / OrMatcher composites. They short-circuit, flatten across chained calls, and report which inner matcher decided the outcome. See Composable Matchers for details.