File Coverage

blib/lib/TAP/Harness/JUnit.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1 6     6   18769 use warnings;
  6         11  
  6         188  
2 6     6   35 use strict;
  6         8  
  6         275  
3              
4             =head1 NAME
5              
6             TAP::Harness::JUnit - Generate JUnit compatible output from TAP results
7              
8             =head1 SYNOPSIS
9              
10             use TAP::Harness::JUnit;
11             my $harness = TAP::Harness::JUnit->new({
12             xmlfile => 'output.xml',
13             package => 'database',
14             # ...
15             });
16             $harness->runtests(@tests);
17              
18             =head1 DESCRIPTION
19              
20             The only difference between this module and I is that this module
21             adds the optional arguments 'xmlfile', 'package', and 'namemangle' that cause
22             the output to be formatted into XML in a format similar to the one that is
23             produced by the JUnit testing framework.
24              
25             =head1 METHODS
26              
27             This module inherits all functions from I.
28              
29             =cut
30              
31             package TAP::Harness::JUnit;
32 6     6   26 use base 'TAP::Harness';
  6         9  
  6         3676  
33              
34 6     6   84241 use Benchmark ':hireswallclock';
  6         22481  
  6         31  
35 6     6   5170 use File::Temp;
  6         75098  
  6         405  
36 6     6   3619 use TAP::Parser;
  6         236603  
  6         199  
37 6     6   5754 use XML::Simple;
  0            
  0            
38             use Scalar::Util qw/blessed/;
39             use Encode;
40              
41             our $VERSION = '0.42';
42              
43             =head2 new
44              
45             These options are added (compared to I):
46              
47             =over
48              
49             =item xmlfile
50              
51             Name of the file XML output will be saved to. If this argument is omitted, the
52             default of "junit_output.xml" is used and a warning is issued.
53              
54             Alternatively, the name of the output file can be specified in the
55             $JUNIT_OUTPUT_FILE environment variable
56              
57             =item package
58              
59             The Hudson/Jenkins continuous-integration systems support separating test
60             results into "packages". By default any number of output xml files will be
61             merged into the default package "(root)".
62              
63             Setting a package name will place all test results from the current run into
64             that package. You can also set the environment variable $JUNIT_PACKAGE to do
65             the same.
66              
67             =item notimes (DEPRECATED)
68              
69             If provided (and true), test case times will not be recorded.
70              
71             =item namemangle
72              
73             Specify how to mangle testcase names. This is sometimes required to interact
74             with buggy JUnit consumers that lack sufficient validation.
75              
76             Alternatively, this value can be set in the environment variable
77             $JUNIT_NAME_MANGLE.
78              
79             Available values are:
80              
81             =over
82              
83             =item hudson
84              
85             Replace anything but alphanumeric characters with underscores. This is the
86             default for historic reasons.
87              
88             =item perl (RECOMMENDED)
89              
90             Replace slashes in the directory hierarchy with dots so that the filesystem
91             layout resembles a Java class hierarchy.
92              
93             This is the recommended setting and may become the default in future.
94              
95             =item none
96              
97             Do not perform any transformations.
98              
99             =back
100              
101             =back
102              
103             =head1 ENVIRONMENT VARIABLES
104              
105             The name of the output file can be specified in the $JUNIT_OUTPUT_FILE
106             environment variable
107              
108             The package name that Hudson/Jenkins use to categorise test results can be
109             specified in $JUNIT_PACKAGE.
110              
111             The name mangling mechanism used to rewrite test names can be specified in
112             $JUNIT_NAME_MANGLE. (See namemangle documentation for available values.)
113              
114             =cut
115              
116             sub new {
117             my ($class, $args) = @_;
118             $args ||= {};
119              
120             # Process arguments
121             my $xmlfile = delete $args->{xmlfile};
122             $xmlfile = $ENV{JUNIT_OUTPUT_FILE} unless defined $xmlfile;
123             unless ($xmlfile) {
124             $xmlfile = 'junit_output.xml';
125             warn 'xmlfile argument not supplied, defaulting to "junit_output.xml"';
126             }
127              
128             my $xmlpackage = delete $args->{package};
129             $xmlpackage = $ENV{JUNIT_PACKAGE} unless defined $xmlpackage;
130              
131             # Get the name of raw perl dump directory
132             my $rawtapdir = $ENV{PERL_TEST_HARNESS_DUMP_TAP};
133             $rawtapdir = $args->{rawtapdir} unless $rawtapdir;
134             $rawtapdir = File::Temp::tempdir() unless $rawtapdir;
135             delete $args->{rawtapdir};
136              
137             my $notimes = delete $args->{notimes};
138              
139             my $namemangle = delete $args->{namemangle};
140             $namemangle = $ENV{JUNIT_NAME_MANGLE} unless defined $namemangle;
141             unless ($namemangle) {
142             $namemangle = 'hudson';
143             }
144              
145             my $self = $class->SUPER::new($args);
146             $self->{__xmlfile} = $xmlfile;
147             $self->{__xml} = {testsuite => []};
148             $self->{__xmlpackage} = $xmlpackage;
149             $self->{__rawtapdir} = $rawtapdir;
150             $self->{__cleantap} = not defined $ENV{PERL_TEST_HARNESS_DUMP_TAP};
151             $self->{__notimes} = $notimes;
152             $self->{__namemangle} = $namemangle;
153             $self->{__auto_number} = 1;
154              
155             # Inject our parser, that persists results for later
156             # consumption and adds timing information
157             @TAP::Harness::JUnit::Parser::ISA = ($self->parser_class);
158             $self->parser_class ('TAP::Harness::JUnit::Parser');
159              
160             return $self;
161             }
162              
163             # Add "(number)" at the end of the test name if the test with
164             # the same name already exists in XML
165             sub uniquename {
166             my $self = shift;
167             my $xml = shift;
168             my $name = shift;
169              
170             my $newname;
171              
172             # Beautify a bit -- strip leading "- "
173             # (that is added by Test::More)
174             $name =~ s/^[\s-]*//;
175              
176             $self->{__test_names} = { map { $_->{name} => 1 } @{ $xml->{testcase} } }
177             unless $self->{__test_names};
178              
179             while(1) {
180             my $number = $self->{__auto_number};
181             $newname = $name
182             ? $name.($number > 1 ? " ($number)" : '')
183             : "Unnamed test case $number"
184             ;
185             last unless exists $self->{__test_names}->{$newname};
186             $self->{__auto_number}++;
187             };
188              
189             $self->{__test_names}->{$newname}++;
190              
191             return xmlsafe($newname);
192             }
193              
194             # Add result of a single TAP parse to the XML
195             sub parsetest {
196             my $self = shift;
197             my $name = shift;
198             my $parser = shift;
199              
200             my $time = $parser->end_time - $parser->start_time;
201             $time = 0 if $self->{__notimes};
202              
203             # Get the return code of test script before re-parsing the TAP output
204             my $badretval = $parser->exit;
205              
206             if ($self->{__namemangle}) {
207             # Older version of hudson crafted an URL of the test
208             # results using the name verbatim. Unfortunatelly,
209             # they didn't escape special characters, soo '/'-s
210             # and family would result in incorrect URLs.
211             # See hudson bug #2167
212             $self->{__namemangle} eq 'hudson'
213             and $name =~ s/[^a-zA-Z0-9, ]/_/g;
214              
215             # Transform hierarchy of directories into what would
216             # look like hierarchy of classes in Hudson
217             if ($self->{__namemangle} eq 'perl') {
218             $name =~ s/^[\.\/]*//;
219             $name =~ s/\./_/g;
220             $name =~ s/\//./g;
221             }
222             }
223              
224             # Hudson/Jenkins strip the prefix from a classname to figure out the package
225             my $prefixname = $self->{__xmlpackage}
226             ? $self->{__xmlpackage}.'.'.$name
227             : $name;
228              
229             my $xml = {
230             name => $prefixname,
231             failures => 0,
232             errors => 0,
233             tests => undef,
234             'time' => $time,
235             testcase => [],
236             'system-out' => [''],
237             skipped => 0,
238             };
239              
240             my $tests_run = 0;
241             my $comment = ''; # Comment agreggator
242             foreach my $result (@{$parser->{__results}}) {
243              
244             my $time = $result->{__end_time} - $result->{__start_time};
245             $time = 0 if $self->{__notimes};
246              
247             # Counters
248             if ($result->type eq 'plan') {
249             $xml->{tests} = $result->tests_planned;
250             }
251              
252             # Comments
253             if ($result->type eq 'comment') {
254             $result->raw =~ /^# (.*)/ and $comment .= xmlsafe($1)."\n";
255             }
256              
257             # Errors
258             if ($result->type eq 'unknown') {
259             $comment .= xmlsafe($result->raw)."\n";
260             }
261              
262             # Test case
263             if ($result->type eq 'test') {
264             $tests_run++;
265              
266             # JUnit can't express these -- pretend they do not exist
267             $result->directive eq 'TODO' and next;
268            
269             my $test = {
270             'time' => $time,
271             name => $self->uniquename($xml, $result->description),
272             classname => $prefixname,
273             };
274              
275             if ($result->ok eq 'not ok') {
276             $test->{failure} = [{
277             type => blessed ($result),
278             message => xmlsafe($result->raw),
279             content => $comment,
280             }];
281             $xml->{failures}++;
282             };
283            
284             if ($result->directive eq 'SKIP') {
285             $test->{skipped} = [{
286             message => xmlsafe($result->raw),
287             }];
288             $xml->{skipped}++;
289             };
290              
291             push @{$xml->{testcase}}, $test;
292             $comment = '';
293             }
294              
295             # Log
296             $xml->{'system-out'}->[0] .= xmlsafe($result->raw)."\n";
297             }
298              
299             # Detect no plan
300             unless (defined $xml->{tests}) {
301             # Ensure XML will have non-empty value
302             $xml->{tests} = 0;
303              
304             # Fake a failed test
305             push @{$xml->{testcase}}, {
306             'time' => $time,
307             name => $self->uniquename($xml, 'Test died too soon, even before plan.'),
308             classname => $prefixname,
309             failure => {
310             type => 'Plan',
311             message => 'The test suite died before a plan was produced. You need to have a plan.',
312             content => 'No plan',
313             },
314             };
315             $xml->{errors}++;
316             }
317              
318             # Detect bad plan
319             elsif ($xml->{errors} = $xml->{tests} - $tests_run) {
320             # Fake an error
321             push @{$xml->{testcase}}, {
322             'time' => $time,
323             name => $self->uniquename($xml, 'Number of runned tests does not match plan.'),
324             classname => $prefixname,
325             failure => {
326             type => 'Plan',
327             message => ($xml->{errors} > 0
328             ? 'Some test were not executed, The test died prematurely.'
329             : 'Extra tests tun.'),
330             content => 'Bad plan',
331             },
332             };
333             $xml->{failures}++;
334             $xml->{errors} = abs ($xml->{errors});
335             }
336              
337             # Bad return value. See BUGS
338             elsif ($badretval and not $xml->{failures}) {
339             # Fake an error
340             push @{$xml->{testcase}}, {
341             'time' => $time,
342             name => $self->uniquename($xml, 'Test returned failure'),
343             classname => $prefixname,
344             failure => {
345             type => 'Died',
346             message => "Test died with return code $badretval",
347             content => "Test died with return code $badretval",
348             },
349             };
350             $xml->{errors}++;
351             $xml->{tests}++;
352             }
353              
354             # Add this suite to XML
355             push @{$self->{__xml}->{testsuite}}, $xml;
356             }
357              
358             sub runtests {
359             my ($self, @files) = @_;
360              
361             my $aggregator = $self->SUPER::runtests(@files);
362              
363             foreach my $test (keys %{$aggregator->{parser_for}}) {
364             $self->parsetest ($test => $aggregator->{parser_for}->{$test});
365             }
366              
367             # Format XML output
368             my $xs = new XML::Simple;
369             my $xml = $xs->XMLout ($self->{__xml}, RootName => 'testsuites');
370              
371             # Ensure it is valid XML. Not very smart though.
372             $xml = encode ('UTF-8', decode ('UTF-8', $xml));
373              
374             # Dump output
375             open my $xml_fh, '>', $self->{__xmlfile}
376             or die $self->{__xmlfile}.': '.$!;
377             print $xml_fh "\n";
378             print $xml_fh $xml;
379             close $xml_fh;
380              
381             # If we caused the dumps to be preserved, clean them
382             File::Path::rmtree($self->{__rawtapdir}) if $self->{__cleantap};
383              
384             return $aggregator;
385             }
386              
387             # Because not all utf8 characters are allowed in xml, only these
388             # Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
389             # http://www.w3.org/TR/REC-xml/#NT-Char
390             sub xmlsafe {
391             my $s = shift;
392              
393             return '' unless defined $s && length($s) > 0;
394              
395             $s =~ s/([\x00|\x01|\x02|\x03|\x04|\x05|\x06|\x07|\x08|\x0B|\x0C|\x0E|\x0F|\x11|\x12|\x13|\x14|\x15|\x16|\x17|\x18|\x19|\x1A|\x1B|\x1C|\x1D|\x1E|\x1F])/ sprintf("<%0.2x>", ord($1)) /gex;
396              
397              
398             return $s;
399             }
400              
401             # This is meant to transparently extend the parser chosen by user.
402             # Dynamically superubclassed to the chosen parser upon harnsess construction.
403             package TAP::Harness::JUnit::Parser;
404              
405             use Time::HiRes qw/time/;
406              
407             # Upon each line taken, account for time and remember the exact
408             # result. A harness should then collect the results from the aggregator.
409             sub next
410             {
411             my $self = shift;
412             my $result = $self->SUPER::next (@_);
413             return $result unless $result; # last call
414              
415             # First assert
416             unless ($self->{__results}) {
417             $self->{__last_assert} = $self->start_time;
418             $self->{__results} = []
419             }
420              
421             # Account for time taken
422             $result->{__start_time} = $self->{__last_assert};
423             $result->{__end_time} = $self->{__last_assert} = time;
424              
425             # Remember for the aggregator
426             push @{$self->{__results}}, $result;
427              
428             return $result;
429             }
430              
431             =head1 SEE ALSO
432              
433             I at L
434              
435             The JUnit XML schema was obtained from
436             L.
437              
438             =head1 ACKNOWLEDGEMENTS
439              
440             This module was partly inspired by Michael Peters's I.
441             It was originally written by Lubomir Rintel (GoodData)
442             C<< >> and includes code from several contributors.
443              
444             The following people (in no specific order) have reported problems or
445             contributed code to I:
446              
447             =over
448              
449             =item David Ritter
450              
451             =item Jeff Lavallee
452              
453             =item Andreas Pohl
454              
455             =item Ton Voon
456              
457             =item Kevin Goess
458              
459             =item Richard Huxton
460              
461             =item David E. Wheeler
462              
463             =item Malcolm Parsons
464              
465             =item Finn Smith
466              
467             =item Toby Broyles
468              
469             =back
470              
471             =head1 BUGS
472              
473             The comments that are above the C or C are considered the output of
474             the test. This, though being more logical, is against TAP specification.
475              
476             I is used to generate the output. This is suboptimal and involves
477             some hacks.
478              
479             During testing the resulting files are not tested against the schema. This
480             would be a good thing to do.
481              
482             =head1 CONTRIBUTING
483              
484             Source code for I is kept in a public Git repository.
485             Visit L.
486              
487             Bug reports and feature enhancement requests are tracked at
488             L.
489              
490             =head1 COPYRIGHT & LICENSE
491              
492             Copyright 2008, 2009, 2010, 2011, 2012, 2013 I
493             contributors. All rights reserved.
494              
495             This program is free software; you can redistribute it and/or modify it under
496             the same terms as Perl itself.
497              
498             =cut
499              
500             1;