Test Your App's JavaScript using Test::Mojo::Role::Phantom

Joel Berger - YAPC::NA 2015

About this talk

Perl's Testing Culture

  • Most (useful) modules on CPAN are very well tested
  • Numerous testing modules on CPAN as well
  • CPAN Testers network (more recently Travis too)

This is Awesome!

How Do We Encourage People To Write Good Test Suites?

Make Testing Easy

Testing A Template


<!DOCTYPE html>
<html>
  <head>
    <title>Simple</title>
  </head>
  <body>
    <p id="name">Leela</p>
  </body>
</html>
    

ex/templates/simple.html.ep

Test::Mojo


use Mojolicious::Lite;

use Test::More;
use Test::Mojo;

any '/' => 'simple';

my $t = Test::Mojo->new;
$t->get_ok('/')->status_is(200)
  ->text_is('title' => 'Simple')
  ->text_is('body p#name' => 'Leela');

done_testing;

    

ex/simple.t

Pretty Easy Right?!

Perl Authors Write

Good Perl Tests

But JavaScript Tests?

How Do We Encourage People To Write JavaScript Tests?

Make JavaScript Testing Easy

Design Goals

  • #1: Do not depend on any external running servers
  • #2: Emit TAP in the normal way Perl tests do
  • #3: Not have to reimplement lots of Test::More functionality

#1: Do not depend on any external running servers

Native Perl

  • Test::Mojo starts a in-process application server
  • Perl can't (natively) interpret JavaScript
  • Even if it could, it would need a DOM

#1: Do not depend on any external running servers

Selenium

Selenium requires external running servers

  • Application server
  • Selenium (remote control) server
  • Browser?

#1: Do not depend on any external running servers

Phantom JS

  • Headless WebKit browser
  • http://phantomjs.org
  • Easy to install
  • Free / Open source
  • We could spin up a phantomjs process on demand?

#2: Emit TAP in the normal way Perl tests do

  • Important for integrating into existing architecture
  • Could use Tape.js from JavaScript side
  • Could use existing Perl Test::More somehow

#2: Emit TAP in the normal way Perl tests do

Practical Considerations

  • JavaScript-side solutions are hard to install
  • Merge JS-emitted TAP into surrounding TAP, correctly?

#3: Not have to reimplement lots of Test::More functionality

  • This is purely for my benefit
  • With #2, leads to a simple solution
    • Use Test::More direct
    • Call Perl functions "from JavaScript"

Test::Mojo::Role::Phantom

  • A test method call
    • start a subtest in Perl
    • start phantomjs
    • set up the javascript environment
  • Send JSON over pipe to the Perl process
    • ["Test::More::ok", 1, "basic check"]
    • Execute inside Perl/subtest
  • Future work: A side-channel websocket also be nice

Testing Dynamics

Testing A Template


<!DOCTYPE html>
<html>
  <head><title>Dynamic</title></head>
  <body>
    <p id="name">Leela</p>
    <script>
      (function(){ 
        document.getElementById('name').innerHTML = 'Bender'; 
      })();
    </script>
  </body>
</html>
    

ex/templates/dynamic.html.ep

Test::Mojo::Role::Phantom


use Mojolicious::Lite; use Test::More;
use Test::Mojo::WithRoles 'Phantom';

any '/' => 'dynamic';

my $t = Test::Mojo::WithRoles->new;
$t->get_ok('/')->status_is(200)
  ->text_is('body p#name' => 'Leela');

$t->phantom_ok('/' => <<'JS');
  var text = page.evaluate(function(){
    return document.getElementById('name').innerHTML;
  });
  perl.is(text, 'Bender', 'name changed after loading');
JS

done_testing;

    

ex/dynamic.t

A More Interesting Example

MomCorp Annihilation Machine


use Mojolicious::Lite;
any '/' => 'click';
app->start;
__DATA__

@@ click.html.ep
<script>
  var current = 0, targets = ['Leela', 'Bender', 'Fry'];
  function cycle() {
    current = (current + 1) % targets.length;
    return targets[current];
  }
</script>
Marked For Annihilation:
<input type="button" id="marked"
       value="Leela" onclick="this.value = cycle()">

    

ex/click.pl


use Mojo::Base -strict; use Test::More;
use Test::Mojo::WithRoles 'Phantom';
require 'ex/click.pl';

my $t = Test::Mojo::WithRoles->new;
$t->phantom_ok('/' => <<'JS');
  function click_get() {
    return page.evaluate(function() {
      var marked = document.getElementById('marked');
      marked.click(); return marked.value;
    })
  }
  perl.is(click_get(), 'Bender', 'once');
  perl.is(click_get(), 'Fry',    'twice');
  perl.is(click_get(), 'Leela',  'three times a lady');
  perl.is(click_get(), 'Bender', 'kiss my shine metal ...');
JS

done_testing;

    

ex/click.t

... But I Don't Use Mojolicious

Dancer Your Thing?


use Dancer;
use Path::Tiny;
my $html = path('ex/templates/dynamic.html.ep')->slurp;
get '/' => sub { return $html };
dance;

    

ex/dancer.pl

Test::Mojo::Role::PSGI Makes This Easy Too


use Mojo::Base -strict; use Test::More;
use Test::Mojo::WithRoles 'PSGI', 'Phantom';

my $t = Test::Mojo::WithRoles->new('ex/dancer.pl');
$t->get_ok('/')->status_is(200)
  ->text_is('body p#name' => 'Leela');

$t->phantom_ok('/' => <<'JS');
  var text = page.evaluate(function(){
    return document.getElementById('name').innerHTML;
  });
  perl.is(text, 'Bender', 'name changed after loading');
JS

done_testing;
    

ex/dancer.t