Categories

Posts in this category

Sun, 18 Dec 2016

Perl 6 By Example: Testing the Timestamp Converter


Permanent link

This blog post is part of my ongoing project to write a book about Perl 6.

If you're interested, please sign up for the mailing list at the bottom of the article, or here. It will be low volume (less than an email per month, on average).


In the previous installment, we've seen some code go through several iterations of refactoring. Refactoring without automated tests tends to make me uneasy, so I actually had a small shell script that called the script under development with several different argument combinations and compared it to an expected result.

Let's now look at a way to write test code in Perl 6 itself.

As a reminder, this is what the code looked like when we left it:

#!/usr/bin/env perl6

#| Convert timestamp to ISO date
multi sub MAIN(Int \timestamp) {
    sub formatter($_) {
        sprintf '%04d-%02d-%02d %02d:%02d:%02d',
                .year, .month,  .day,
                .hour, .minute, .second,
    }
    given DateTime.new(+timestamp, :&formatter) {
        when .Date.DateTime == $_ { say .Date }
        default { .say }
    }
}

#| Convert ISO date to timestamp
multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
    my $d = Date.new($date);
    if $time {
        my ( $hour, $minute, $second ) = $time.split(':');
        say DateTime.new(date => $d, :$hour, :$minute, :$second).posix;
    }
    else {
        say $d.DateTime.posix;
    }
}

In the Perl community it's common to move logic into modules to make it easier to test with external test scripts. In Perl 6, that's still common, but for small tools such as this, I prefer to stick with a single file containing code and tests, and to run the tests via a separate test command.

To make testing easier, let's first separate I/O from the application logic:

#!/usr/bin/env perl6

sub from-timestamp(Int \timestamp) {
    sub formatter($_) {
        sprintf '%04d-%02d-%02d %02d:%02d:%02d',
                .year, .month,  .day,
                .hour, .minute, .second,
    }
    given DateTime.new(+timestamp, :&formatter) {
        when .Date.DateTime == $_ { return .Date }
        default { return $_ }
    }
}

sub from-date-string(Str $date, Str $time?) {
    my $d = Date.new($date);
    if $time {
        my ( $hour, $minute, $second ) = $time.split(':');
        return DateTime.new(date => $d, :$hour, :$minute, :$second);
    }
    else {
        return $d.DateTime;
    }
}

#| Convert timestamp to ISO date
multi sub MAIN(Int \timestamp) {
    say from-timestamp(+timestamp);
}

#| Convert ISO date to timestamp
multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
    say from-date-string($date, $time).posix;
}

With this small refactoring out of the way, let's add some tests:

#| Run internal tests
multi sub MAIN('test') {
    use Test;
    plan 4;
    is-deeply from-timestamp(1450915200), Date.new('2015-12-24'),
        'Timestamp to Date';;

    my $dt = from-timestamp(1450915201);
    is $dt, "2015-12-24 00:00:01",
        'Timestamp to DateTime with string formatting';

    is from-date-string('2015-12-24').posix, 1450915200,
        'from-date-string, one argument';
    is from-date-string('2015-12-24', '00:00:01').posix, 1450915201,
        'from-date-string, two arguments';
}

And you can run it:

./autotime test
1..4
ok 1 - Timestamp to Date
ok 2 - Timestamp to DateTime with string formatting
ok 3 - from-date-string, one argument
ok 4 - from-date-string, two arguments

The output format is that of the Test Anything Protocol (TAP), which is the de facto standard in the Perl community, but is now also used in other communities. For larger output strings it is a good idea to run the tests through a test harness. For our four lines of test output, this isn't yet necessary, but if you want to do that anyway, you can use the prove program that's shipped with Perl 5:

$ prove -e "" "./autotime test"
./autotime-tested.p6 test .. ok
All tests successful.
Files=1, Tests=4,  0 wallclock secs ( 0.02 usr  0.01 sys +  0.23 cusr  0.02 csys =  0.28 CPU)
Result: PASS

In a terminal, this even colors the "All tests successful" output in green, to make it easier to spot. Test failures are marked up in red.

How does the testing work? The first line of code uses a new feature we haven't seen yet:

multi sub MAIN('test') {

What's that, a literal instead of a parameter in the subroutine signature? That's right. And it's a shortcut for

multi sub MAIN(Str $anon where {$anon eq 'test'}) {

except that it does not declare the variable $anon. So it's a multi candidate that you can only call by supplying the string 'test' as the sole argument.

The next line, use Test;, loads the test module that's shipped with Rakudo Perl 6. It also imports into the current lexical scope all the symbols that Test exports by default. This includes the functions plan, is and is-deeply that are used later on.

plan 4 declares that we want to run four tests. This is useful for detecting unplanned, early exits from the test code, or errors in looping logic in the test code that leads to running fewer tests than planned. If you can't be bothered to count your tests in advance, you can leave out the plan call, and instead call done-testing after your tests are done.

Both is-deeply and is expect the value to be tested as the first argument, the expected value as the second argument, and an optional test label string as the third argument. The difference is that is() compares the first two arguments as strings, whereas is-deeply uses a deep equality comparison logic using the eqv operator. Such tests only pass if the two arguments are of the same type, and recursively are (or contain) the same values.

More testing functions are available, like ok(), which succeeds for a true argument, and nok(), which expects a false argument. You can also nest tests with subtest:

#| Run internal tests
multi sub MAIN('test') {
    use Test;
    plan 2;
    subtest 'timestamp', {
        plan 2;
        is-deeply from-timestamp(1450915200), Date.new('2015-12-24'),
            'Date';;

        my $dt = from-timestamp(1450915201);
        is $dt, "2015-12-24 00:00:01",
            'DateTime with string formatting';
    };

    subtest 'from-date-string', {
        plan 2;
        is from-date-string('2015-12-24').posix, 1450915200,
            'one argument';
        is from-date-string('2015-12-24', '00:00:01').posix, 1450915201,
            'two arguments';
    };
}

Each call to subtest counts as a single test to the outer test run, so plan 4; has become plan 2;. The subtest call has a test label itself, and then inside a subtest, you have a plan again, and calls to test functions as below. This is very useful when writing custom test functions that execute a variable number of individual tests.

The output from the nested tests looks like this:

1..2
    1..2
    ok 1 - Date
    ok 2 - DateTime with string formatting
ok 1 - timestamp
    1..2
    ok 1 - one argument
    ok 2 - two arguments
ok 2 - from-date-string

The test harness now reports just the two top-level tests as the number of run (and passed) tests.

And yes, you can nest subtests within subtests, should you really feel the urge to do so.

Subscribe to the Perl 6 book mailing list

* indicates required

[/perl-6] Permanent link