This post assumes that you know you way around Mojolicious web framework. If not you should start here:
Testing web applications be can tricky because it involves many different components like the html, java script, css, databases and last but not least the Perl code.
Fortunately the Perl ecosystem is very rich in test libraries.
For the purpose of this article I will use the following libraries:
Test2::V0
Test::Mojo
DBD::Mock::Session::GenerateFixtures
Sub::Override
Playwright
Proc::Background
File::Path
File::Copy
For our first test, we’ll use Test::Mojo, the official testing helper for Mojolicious. It provides a rich set of methods for simulating HTTP requests and making assertions about web applications. Best of all, it can run tests without requiring a Mojolicious server to be running.
Now let's break down what we need to do. First, we need to use some sort of test database. There are many options here. Personally, I like to create fixtures using DBD::Mock::Session::GenerateFixtures module, a thin wrapper over DBD::Mock that generates fixtures by copying them into a JSON file when a real dbh is provided. Aftter the mocked is generated I Sub::Override to make sure the real dbh is overridden by the mocked one.
Using Test::Mojo
use strict;
use warnings;
use Mojo::Base -strict;
use Test2::V0;
use Test::Mojo;
my $override = Sub::Override->new();
my $db = MyApp::DB->new(domain => 'development', type => 'main');
# when dbh attribute is set it copies data in t/db_fixtures/02_login.t.json
# when dbh attribute is not set will use the test name to relove the fixture file
my $mock_dumper = DBD::Mock::Session::GenerateFixtures->new({dbh => $db->dbh()});
# override in the app the dbh
$override->replace('Rose::DB::dbh' => sub {return $mock_dumper->get_dbh})
With this approach after the fixtures file is generated we can start using Test::Mojo with the web server down.
No lets add some tests in our code:
subtest 'test login with correct user and password' => sub {
$t->get_ok('/')->status_is(200);
my $login_url = $t->tx->res->dom->find('a')->grep(qr/login/)->map(attr => 'href')->first;
ok($login_url, "Found Login link pointing to $login_url");
my $response = $t->post_ok($login_url => form => { username => 'Diana', password => 'password' });
$response->status_is(302);
my $cookie = $response->tx->res->cookies->[0];
is($cookie->name, 'mojolicious', "The Mojolicious session cookie is set");
ok(length $cookie->value, "Cookie has a value");
my $redirect_url = $response->tx->res->headers->location();
my $followed = $t->get_ok($redirect_url)->status_is(200);
my $log_out_url = $response->tx->res->dom->find('a')->grep(qr/logout/)->map(attr => 'href')->first;
ok($log_out_url, "Found login out link pointing to $log_out_url");
};
Save everything in a file t/login.t and run:
carton exec -- prove -v t/login.t
This should generate a similar output:
[2025-08-24 14:35:14.51780] [643942] [trace] [tMiiGo7o61v9] GET "/"
[2025-08-24 14:35:14.51799] [643942] [trace] [tMiiGo7o61v9] Routing to controller "SkinCare::Controller::Welcome" and action "landing_page"
[2025-08-24 14:35:14.51848] [643942] [trace] [tMiiGo7o61v9] Rendering template "example/welcome.html.ep"
[2025-08-24 14:35:14.51933] [643942] [trace] [tMiiGo7o61v9] Rendering template "layouts/default.html.ep"
[2025-08-24 14:35:14.52067] [643942] [trace] [tMiiGo7o61v9] 200 OK (0.002865s, 349.040/s)
ok 1 - test login with correct user and password {
ok 1 - GET /
ok 2 - 200 OK
ok 3 - Found Login link pointing to /login
ok 4 - POST /login
ok 5 - 302 Found
ok 6 - GET /welcome/1
ok 7 - 200 OK
ok 8 - Found login out link pointing to /logout
ok 9 - The Mojolicious session cookie is set
ok 10 - Cookie has a value
1..10
}
So what just happened:
loaded the landing and tested the status:
$t->get_ok('/')->status_is(200)
Then we searched for the login url inside the dom using Mojo::Dom:
$t->tx->res->dom->find('a')->grep(qr/login/)->map(attr => 'href')->first
Submitted the login form and tested the response
$response->status_is(302);
my $cookie = $response->tx->res->cookies->[0];
is($cookie->name, 'mojolicious', "The Mojolicious session cookie is set");
ok(length $cookie->value, "Cookie has a value");;
- Checked that the user home page loaded successfully by searching for links:
my $redirect_url = $response->tx->res->headers->location();
my $followed = $t->get_ok($redirect_url)->status_is(200);
my $log_out_url = $response->tx->res->dom->find('a')->grep(qr/logout/)->map(attr => 'href')->first;
ok($log_out_url, "Found login out link pointing to $log_out_url");
This small tests hit all my code and illustrates the advantages of Test::Mojo
:
- No extra setup.
- Easy to parse the DOM with it.
- Can run tests without the server being up.
Using Playwright:
If you need to test java script and Test::Mojo
quickly reaches its limits. A good alternative to it is the Perl client for Playwright.
Because Playwright requires the web server to be up an running several tweaks are required:
- You need to install
nodejs
and friends:
apt-get install -y nodejs
npm install -g playwright
npx playwright install --with-deps chromium
npm install -g express
- You need to integrated the mock mechanism in the application code:
if ($ENV{FIXTURES_PATH}) {
if ($ENV{GENERATE_FIXTURES}) {
my $override = Sub::Override->new();
my $mock_dumper = DBD::Mock::Session::GenerateFixtures->new({dbh => __PACKAGE__->new(domain => 'development', type => 'main')->dbh()});
$override->replace('Rose::DB::dbh' => sub {return $mock_dumper->get_dbh});
move "script/db_fixtures/skin_care.json", $ENV{FIXTURES_PATH};
} elsif($ENV{USE_FIXTURES}) {
my $override = Sub::Override->new();
my $mock_dumper = DBD::Mock::Session::GenerateFixtures->new(file => $ENV{GENERATE_FIXTURES_PATH});
$override->replace('Rose::DB::dbh' => sub {return $mock_dumper->get_dbh});
}
}
- You need a test that can start and stop your server:
my $proc = Proc::Background->new("carton exec -- morbo ../skin_care/script/skin_care") or die "$!";
sleep 3;
...
if ($proc->alive) {
$proc->terminate;
$proc->wait;
}
Playwright test setup:
use strict;
use warnings;
use Test2::V0;
use English qw ( -no_match_vars );
use Data::Dumper;
use Playwright;
use Proc::Background;
use File::Path qw( rmtree );
use feature 'say';
my $handle = Playwright->new();
my $browser = $handle->launch( headless => 0, type => 'chrome' );
my $page = $browser->newPage();
$ENV{GENERATE_FIXTURES} = 0;
my ($volume, $directory, $test_file) = File::Spec->splitpath($PROGRAM_NAME);
$ENV{FIXTURES_PATH} = "t/db_fixtures/$test_file.json";
$ENV{USE_FIXTURES} = 1;
my $proc = Proc::Background->new("carton exec -- morbo ../skin_care/script/skin_care") or die "$!";
sleep 3;
This above sets the env vars used by the application to determine if it generate or use a fixture file for or test and sets up the browser.
Example Playwright test:
my $res = $page->goto('http://127.0.0.1:3000/login', { waitUntil => 'networkidle' });
$page->screenshot({path => '01_load_form.png'});
is($res->status(), 200, 'login form is ok');
$page->fill('input[name="username"]', 'Diana');
$page->fill('input[name="password"]', 'password');
$page->screenshot({path => '02_populate_form_from.png'});
$page->click('input[type="submit"].btn-primary');
my $cookies = $page->context->cookies();
is($cookies->[0]->{name}, 'mojolicious', "The Mojolicious session cookie is set");
ok(length $cookies->[0]->{value}, "Cookie has a value");
$page->screenshot({path => '03_user_home.png'});
my $create_routine_locator = $page->locator('#create_routine');
my $create_routine_menu = $create_routine_locator->allInnerTexts()->[0];
is($create_routine_menu, 'Create a routine', 'Create routine menu is ok');
$create_routine_locator->click();
$page->waitForSelector('#btn-1', { state => 'visible', timeout => 5000 });
$page->click('#btn-1');
sleep 3;
$page->screenshot({path => '04_user_sub_menu.png'});
my $create_routine_content_locator = $page->locator('#content-1');
ok($create_routine_content_locator->count() > 0, 'Found #content-1 in the DOM');
So what just happened:
- loaded the landing and tested the status
- Fill the input fields with the credentials and submitted the form
- Tested the login via the cookies
- Use various methods like
click
orlocator
to interact and test the page. - create snapshots with of the various tested process for an human review.
Top comments (0)