DEV Community

Cover image for Testing a Perl Mojolicious web app
DragosTrif
DragosTrif

Posted on • Edited on

Testing a Perl Mojolicious web app

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
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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");
};
Enter fullscreen mode Exit fullscreen mode

Save everything in a file t/login.t and run:

carton exec -- prove -v t/login.t
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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");;
Enter fullscreen mode Exit fullscreen mode
  • 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");  
Enter fullscreen mode Exit fullscreen mode

This small tests hit all my code and illustrates the advantages of Test::Mojo:

  1. No extra setup.
  2. Easy to parse the DOM with it.
  3. 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:

  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. 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});
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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;
}
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

So what just happened:

  1. loaded the landing and tested the status
  2. Fill the input fields with the credentials and submitted the form
  3. Tested the login via the cookies
  4. Use various methods like click or locator to interact and test the page.
  5. create snapshots with of the various tested process for an human review.

load login form

populate login form

show user home page

Top comments (0)