1
0

Mapper.pm 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. package Test::Mapper;
  2. use strict;
  3. use warnings;
  4. our $VERSION = '0.001';
  5. #ABSTRACT: Map what parts of a codebase changing ought trigger acceptance tests
  6. =head1 DESCRIPTION
  7. The general tradition in perl unit testing is to map 'what to test' when a piece of code changes is simple:
  8. lib/Foo/Bar.pm -> t/lib-Foo-Bar.t (or t/lib-Foo/Bar.t)
  9. bin/baz -> t/bin-baz.t
  10. Unfortunately things tend to get messy when you engage in more complicated testing, such as integration testing.
  11. There, you have one-to-many relationships, but which can still be easily discerned programmatically via inspecting @INC.
  12. That is unless you require() modules (or worse, read & eval/exec), in which case analysis via PPI must be brought to bear.
  13. Still, most people solve that particular problem with the doctor's adage about "not doing things that hurt".
  14. However, acceptance tests will blow out your build server's thinking budget quickly, being a many-to-many mapping.
  15. To make things worse, much of the stack isn't necessarily in $LANGUAGE_OF_CHOICE.
  16. Furthermore this is not something that can simply be avoided via careful structuring.
  17. Knowing all this, what then shall we do? Cheat.
  18. AKA breaking an insoluble problem into many smaller soluble ones.
  19. It is well within the ability of a test author to discern the route via which an acceptance test accesses a feature.
  20. Indeed this is necessarily so.
  21. As such if we structure our mapping from this perspective life becomes quickly simplified.
  22. Taking as an example an average web application, we can imagine a mapping like:
  23. t/acceptance/DoFoobarFeature.t -> GET /ez/bez, POST /huth/buth, ...
  24. And every single so loaded "endpoint" necessarily has a number of resources loaded.
  25. It is well within the realm of possibility to hit these endpoints with L<Playwright>, grab a HttpARchive and map that to the relevant source files in the repo.
  26. Similarly one can (in most cases) do the same thing with normal applications via C<ldd>, C<strace> and so forth.
  27. =head1 SYNOPSIS
  28. use Test::Mapper;
  29. my $mapper = Test::Mapper->new(
  30. db => '/path/to/mapper.db',
  31. product => 'MyApp',
  32. environment => 'linux',
  33. );
  34. # Record that a test exercises certain source files
  35. $mapper->map_test(
  36. test => 't/acceptance/login.t',
  37. resources => [ 'lib/MyApp/Auth.pm', 'lib/MyApp/Session.pm' ],
  38. );
  39. # Given a list of changed files, find which tests need to run
  40. my @tests = $mapper->tests_for_resources(
  41. resources => [ 'lib/MyApp/Auth.pm' ],
  42. );
  43. =head1 METHODS
  44. =head2 new(%args)
  45. Constructor. Required args:
  46. =over 4
  47. =item db
  48. Path to the SQLite database file.
  49. =item product
  50. Product name string. Used to namespace data across products in a shared DB.
  51. =item environment
  52. Environment name (e.g. C<linux>, C<MSWin32>). Tests may exercise different
  53. resources on different platforms.
  54. =back
  55. =head2 map_test(%args)
  56. Record that C<test> (a test filename) exercises C<resources> (arrayref of
  57. source filenames). Idempotent — safe to call on every analysis run.
  58. =head2 tests_for_resources(%args)
  59. Given C<resources> (arrayref of changed filenames), return a list of test
  60. filenames that should be run.
  61. =cut
  62. use Test::Mapper::DB;
  63. sub new {
  64. my ( $class, %args ) = @_;
  65. die "db is required" unless $args{db};
  66. die "product is required" unless $args{product};
  67. die "environment is required" unless $args{environment};
  68. my $dbh = Test::Mapper::DB::dbh( $args{db} );
  69. my $self = bless {
  70. dbh => $dbh,
  71. product => $args{product},
  72. environment => $args{environment},
  73. _product_id => undef,
  74. _env_id => undef,
  75. }, $class;
  76. $self->_ensure_product;
  77. $self->_ensure_environment;
  78. return $self;
  79. }
  80. sub map_test {
  81. my ( $self, %args ) = @_;
  82. die "test is required" unless $args{test};
  83. die "resources is required" unless $args{resources};
  84. my $dbh = $self->{dbh};
  85. my $test_id = $self->_ensure_test( $args{test} );
  86. for my $filename ( @{ $args{resources} } ) {
  87. my $resource_id = $self->_ensure_resource($filename);
  88. $dbh->do(
  89. "INSERT OR IGNORE INTO tested_by (resource_id, test_id) VALUES (?, ?)",
  90. undef, $resource_id, $test_id
  91. ) or die "Could not map test: " . $dbh->errstr;
  92. }
  93. return;
  94. }
  95. sub tests_for_resources {
  96. my ( $self, %args ) = @_;
  97. die "resources is required" unless $args{resources};
  98. my $dbh = $self->{dbh};
  99. my $env_id = $self->{_env_id};
  100. my $placeholders = join( ",", ("?") x scalar @{ $args{resources} } );
  101. my $sth = $dbh->prepare(
  102. "SELECT DISTINCT t.filename
  103. FROM test AS t
  104. JOIN tested_by AS tb ON tb.test_id = t.id
  105. JOIN resource AS r ON r.id = tb.resource_id
  106. WHERE r.environment_id = ?
  107. AND r.filename IN ($placeholders)"
  108. ) or die "Could not prepare query: " . $dbh->errstr;
  109. $sth->execute( $env_id, @{ $args{resources} } )
  110. or die "Could not execute query: " . $sth->errstr;
  111. my @tests;
  112. while ( my ($filename) = $sth->fetchrow_array ) {
  113. push @tests, $filename;
  114. }
  115. return @tests;
  116. }
  117. # ---- private helpers ----
  118. sub _ensure_product {
  119. my ($self) = @_;
  120. my $dbh = $self->{dbh};
  121. $dbh->do(
  122. "INSERT OR IGNORE INTO product (product) VALUES (?)",
  123. undef, $self->{product}
  124. ) or die $dbh->errstr;
  125. my ($id) = $dbh->selectrow_array(
  126. "SELECT id FROM product WHERE product=?",
  127. undef, $self->{product}
  128. );
  129. $self->{_product_id} = $id;
  130. return $id;
  131. }
  132. sub _ensure_environment {
  133. my ($self) = @_;
  134. my $dbh = $self->{dbh};
  135. $dbh->do(
  136. "INSERT OR IGNORE INTO environment (environment, product_id) VALUES (?, ?)",
  137. undef, $self->{environment}, $self->{_product_id}
  138. ) or die $dbh->errstr;
  139. my ($id) = $dbh->selectrow_array(
  140. "SELECT id FROM environment WHERE environment=? AND product_id=?",
  141. undef, $self->{environment}, $self->{_product_id}
  142. );
  143. $self->{_env_id} = $id;
  144. return $id;
  145. }
  146. sub _ensure_test {
  147. my ( $self, $filename ) = @_;
  148. my $dbh = $self->{dbh};
  149. $dbh->do(
  150. "INSERT OR IGNORE INTO test (filename, product_id) VALUES (?, ?)",
  151. undef, $filename, $self->{_product_id}
  152. ) or die $dbh->errstr;
  153. my ($id) = $dbh->selectrow_array(
  154. "SELECT id FROM test WHERE filename=? AND product_id=?",
  155. undef, $filename, $self->{_product_id}
  156. );
  157. return $id;
  158. }
  159. sub _ensure_resource {
  160. my ( $self, $filename ) = @_;
  161. my $dbh = $self->{dbh};
  162. $dbh->do(
  163. "INSERT OR IGNORE INTO resource (environment_id, filename) VALUES (?, ?)",
  164. undef, $self->{_env_id}, $filename
  165. ) or die $dbh->errstr;
  166. my ($id) = $dbh->selectrow_array(
  167. "SELECT id FROM resource WHERE environment_id=? AND filename=?",
  168. undef, $self->{_env_id}, $filename
  169. );
  170. return $id;
  171. }
  172. 1;