Skip to content

Test support

MVC::Keayl::TestSupport drives requests through an application in-process and asserts on the result, with helpers for routing, controllers, mailers, jobs, and cable. Every assertion throws X::MVC::Keayl::Test::AssertionFailed on failure and returns on success, so it works with Test (lives-ok/dies-ok) and with BDD::Behave (expect({ ... }).not.to.throw).

Integration sessions

An IntegrationSession wraps any application endpoint (a dispatcher or the full Application.endpoint) and issues requests, persisting the cookie jar across them so sessions and flash survive:

1
2
3
4
5
6
7
use MVC::Keayl::TestSupport;

my $session = IntegrationSession.new(app => $dispatcher);

$session.get('/login');
$session.post('/sessions', body => 'user=ada');
$session.get('/dashboard');     # the session cookie from the login is sent back

get, post, put, patch, and delete take a target and optional headers and body. The last response is session.response. follow-redirect issues the redirected request after a 3xx.

Response assertions

1
2
3
$session.assert-response(200);          # or a name: 'ok', 'not-found', ...
$session.assert-redirected-to('/login');
$session.assert-select('Welcome');      # substring or a Regex over the body

assert-select matches against the response body (a string substring or a regex), with an optional text => to require additional content.

Live server

IntegrationSession drives the app in memory, which covers most tests. When a test needs a real listening socket — driving the app from an external HTTP client or a browser — LiveServer serves a built endpoint over the Cro adapter on an ephemeral port:

1
2
3
4
5
6
7
8
use MVC::Keayl::TestSupport;

my $server = LiveServer.new(app => $application.endpoint).start;

my $url = $server.url('/dashboard');   # http://127.0.0.1:<port>/dashboard
# ... drive $url with any HTTP client or browser ...

$server.stop;

new picks a free localhost port; override it with host, port, or scheme. start binds the socket and returns the server, base-url and url($path) build addresses against it, and stop shuts it down. Each LiveServer defaults to a distinct port, so several can run at once.

Routing assertions

These check a router both ways:

1
2
3
assert-recognizes($router, 'GET', '/widgets/5', matching => %( controller => 'widgets', action => 'show', id => '5' ));
assert-generates($router, 'widget', '/widgets/5', 5);
assert-routing($router, 'widget', 'GET', '/widgets/5', matching => %( action => 'show' ), 5);

Controller and view introspection

RecordingRenderer stands in for the view renderer and records what was rendered, so a controller test can dispatch an action and inspect the result:

1
2
3
4
5
6
my $renderer   = RecordingRenderer.new;
my $controller = WidgetsController.new(view-renderer => $renderer);
$controller.dispatch('show');

assert-rendered($renderer, 'show');
assert-assigned($controller, 'widget', $widget);

Component helpers

Mailer, job, and cable activity is asserted around a block:

1
2
3
4
5
6
7
8
assert-emails(1, { UserMailer.new(delivery => $test-delivery).deliver('welcome', $user) });
delivered-emails();   # the collected test deliveries

assert-enqueued-jobs(2, $test-adapter, { ImportJob.perform-later($a); ImportJob.perform-later($b) });
perform-enqueued-jobs($test-adapter);

assert-broadcasts($pubsub, 'room:1', 2, { $pubsub.broadcast('room:1', 'a'); $pubsub.broadcast('room:1', 'b') });
assert-stream-subscribed($channel, 'room:1');

With BDD::Behave

The same helpers read naturally in a behave spec by wrapping the assertion in a block:

1
2
3
4
5
6
7
8
describe 'the dashboard', {
  let(:session, { IntegrationSession.new(app => $app.endpoint) });

  it 'greets a signed-in user', {
    session.get('/dashboard');
    expect({ session.assert-select('Welcome') }).not.to.throw;
  }
}