1
0

Mapper.pm 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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. =head2 start_build(%args)
  62. Queue tests for a build. Required: C<build> (build identifier, e.g. a git SHA),
  63. C<resources> (arrayref of changed filenames). Returns a list of test filenames
  64. queued for this build.
  65. =head2 pending_tests(%args)
  66. Return unclaimed test filenames for a build. Required: C<build>.
  67. =head2 claim_test(%args)
  68. Atomically claim a test for a worker. Required: C<build>, C<test>, C<worker>.
  69. Returns 1 if claimed, 0 if already claimed by another worker.
  70. =head2 complete_test(%args)
  71. Record a test result. Required: C<build>, C<test>, C<result> (integer; 0=pass,
  72. non-zero=fail convention recommended). Sets finish timestamp automatically.
  73. =head2 build_results(%args)
  74. Return all results for a build as an arrayref of hashrefs, each with keys
  75. C<test_file>, C<run_by>, C<result>, C<start>, C<finish>. Required: C<build>.
  76. =cut
  77. use Test::Mapper::DB;
  78. sub new {
  79. my ( $class, %args ) = @_;
  80. die "db is required" unless $args{db};
  81. die "product is required" unless $args{product};
  82. die "environment is required" unless $args{environment};
  83. my $dbh = Test::Mapper::DB::dbh( $args{db} );
  84. my $self = bless {
  85. dbh => $dbh,
  86. product => $args{product},
  87. environment => $args{environment},
  88. _product_id => undef,
  89. _env_id => undef,
  90. }, $class;
  91. $self->_ensure_product;
  92. $self->_ensure_environment;
  93. return $self;
  94. }
  95. sub map_test {
  96. my ( $self, %args ) = @_;
  97. die "test is required" unless $args{test};
  98. die "resources is required" unless $args{resources};
  99. my $dbh = $self->{dbh};
  100. my $test_id = $self->_ensure_test( $args{test} );
  101. for my $filename ( @{ $args{resources} } ) {
  102. my $resource_id = $self->_ensure_resource($filename);
  103. $dbh->do(
  104. "INSERT OR IGNORE INTO tested_by (resource_id, test_id) VALUES (?, ?)",
  105. undef, $resource_id, $test_id
  106. ) or die "Could not map test: " . $dbh->errstr;
  107. }
  108. return;
  109. }
  110. sub tests_for_resources {
  111. my ( $self, %args ) = @_;
  112. die "resources is required" unless $args{resources};
  113. my $dbh = $self->{dbh};
  114. my $env_id = $self->{_env_id};
  115. my $placeholders = join( ",", ("?") x scalar @{ $args{resources} } );
  116. my $sth = $dbh->prepare(
  117. "SELECT DISTINCT t.filename
  118. FROM test AS t
  119. JOIN tested_by AS tb ON tb.test_id = t.id
  120. JOIN resource AS r ON r.id = tb.resource_id
  121. WHERE r.environment_id = ?
  122. AND r.filename IN ($placeholders)"
  123. ) or die "Could not prepare query: " . $dbh->errstr;
  124. $sth->execute( $env_id, @{ $args{resources} } )
  125. or die "Could not execute query: " . $sth->errstr;
  126. my @tests;
  127. while ( my ($filename) = $sth->fetchrow_array ) {
  128. push @tests, $filename;
  129. }
  130. return @tests;
  131. }
  132. sub start_build {
  133. my ( $self, %args ) = @_;
  134. die "build is required" unless $args{build};
  135. die "resources is required" unless $args{resources};
  136. my $dbh = $self->{dbh};
  137. my $build_id = $self->_ensure_build( $args{build} );
  138. my $env_id = $self->{_env_id};
  139. return () unless @{ $args{resources} };
  140. my $placeholders = join( ",", ("?") x scalar @{ $args{resources} } );
  141. $dbh->do(
  142. "INSERT OR IGNORE INTO test_for_build (build_id, test_id)
  143. SELECT ?, tr.test_id
  144. FROM to_run AS tr
  145. WHERE tr.environment = (SELECT environment FROM environment WHERE id = ?)
  146. AND tr.source_file IN ($placeholders)",
  147. undef, $build_id, $env_id, @{ $args{resources} }
  148. ) or die "Could not queue tests for build: " . $dbh->errstr;
  149. my $sth = $dbh->prepare(
  150. "SELECT t.filename
  151. FROM test AS t
  152. JOIN test_for_build AS tfb ON tfb.test_id = t.id
  153. WHERE tfb.build_id = ?"
  154. ) or die "Could not prepare query: " . $dbh->errstr;
  155. $sth->execute($build_id) or die $sth->errstr;
  156. my @tests;
  157. while ( my ($filename) = $sth->fetchrow_array ) {
  158. push @tests, $filename;
  159. }
  160. return @tests;
  161. }
  162. sub pending_tests {
  163. my ( $self, %args ) = @_;
  164. die "build is required" unless $args{build};
  165. my $dbh = $self->{dbh};
  166. my ($build_id) = $dbh->selectrow_array(
  167. "SELECT id FROM build WHERE build=? AND product_id=?",
  168. undef, $args{build}, $self->{_product_id}
  169. );
  170. return () unless $build_id;
  171. my $sth = $dbh->prepare(
  172. "SELECT t.filename
  173. FROM test AS t
  174. JOIN test_for_build AS tfb ON tfb.test_id = t.id
  175. WHERE tfb.build_id = ? AND tfb.run_by IS NULL"
  176. ) or die "Could not prepare query: " . $dbh->errstr;
  177. $sth->execute($build_id) or die $sth->errstr;
  178. my @tests;
  179. while ( my ($filename) = $sth->fetchrow_array ) {
  180. push @tests, $filename;
  181. }
  182. return @tests;
  183. }
  184. sub claim_test {
  185. my ( $self, %args ) = @_;
  186. die "build is required" unless $args{build};
  187. die "test is required" unless $args{test};
  188. die "worker is required" unless $args{worker};
  189. my $dbh = $self->{dbh};
  190. my $build_id = $self->_build_id( $args{build} ) // return 0;
  191. my $test_id = $self->_test_id( $args{test} ) // return 0;
  192. my $rows = $dbh->do(
  193. "UPDATE test_for_build SET run_by=?
  194. WHERE build_id=? AND test_id=? AND run_by IS NULL",
  195. undef, $args{worker}, $build_id, $test_id
  196. );
  197. return ( $rows && $rows > 0 ) ? 1 : 0;
  198. }
  199. sub complete_test {
  200. my ( $self, %args ) = @_;
  201. die "build is required" unless $args{build};
  202. die "test is required" unless $args{test};
  203. die "result is required" unless defined $args{result};
  204. my $dbh = $self->{dbh};
  205. my $build_id = $self->_build_id( $args{build} ) // die "Unknown build: $args{build}";
  206. my $test_id = $self->_test_id( $args{test} ) // die "Unknown test: $args{test}";
  207. $dbh->do(
  208. "UPDATE test_for_build SET result=?, finish=CURRENT_TIMESTAMP
  209. WHERE build_id=? AND test_id=?",
  210. undef, $args{result}, $build_id, $test_id
  211. ) or die "Could not complete test: " . $dbh->errstr;
  212. return;
  213. }
  214. sub build_results {
  215. my ( $self, %args ) = @_;
  216. die "build is required" unless $args{build};
  217. my $dbh = $self->{dbh};
  218. my $sth = $dbh->prepare(
  219. "SELECT t.filename, tfb.run_by, tfb.result, tfb.start, tfb.finish
  220. FROM test_for_build AS tfb
  221. JOIN test AS t ON t.id = tfb.test_id
  222. JOIN build AS b ON b.id = tfb.build_id
  223. WHERE b.build=? AND b.product_id=?"
  224. ) or die "Could not prepare query: " . $dbh->errstr;
  225. $sth->execute( $args{build}, $self->{_product_id} ) or die $sth->errstr;
  226. my @results;
  227. while ( my $row = $sth->fetchrow_hashref ) {
  228. push @results, {
  229. test_file => $row->{filename},
  230. run_by => $row->{run_by},
  231. result => $row->{result},
  232. start => $row->{start},
  233. finish => $row->{finish},
  234. };
  235. }
  236. return \@results;
  237. }
  238. # ---- private helpers ----
  239. sub _ensure_product {
  240. my ($self) = @_;
  241. my $dbh = $self->{dbh};
  242. $dbh->do(
  243. "INSERT OR IGNORE INTO product (product) VALUES (?)",
  244. undef, $self->{product}
  245. ) or die $dbh->errstr;
  246. my ($id) = $dbh->selectrow_array(
  247. "SELECT id FROM product WHERE product=?",
  248. undef, $self->{product}
  249. );
  250. $self->{_product_id} = $id;
  251. return $id;
  252. }
  253. sub _ensure_environment {
  254. my ($self) = @_;
  255. my $dbh = $self->{dbh};
  256. $dbh->do(
  257. "INSERT OR IGNORE INTO environment (environment, product_id) VALUES (?, ?)",
  258. undef, $self->{environment}, $self->{_product_id}
  259. ) or die $dbh->errstr;
  260. my ($id) = $dbh->selectrow_array(
  261. "SELECT id FROM environment WHERE environment=? AND product_id=?",
  262. undef, $self->{environment}, $self->{_product_id}
  263. );
  264. $self->{_env_id} = $id;
  265. return $id;
  266. }
  267. sub _ensure_test {
  268. my ( $self, $filename ) = @_;
  269. my $dbh = $self->{dbh};
  270. $dbh->do(
  271. "INSERT OR IGNORE INTO test (filename, product_id) VALUES (?, ?)",
  272. undef, $filename, $self->{_product_id}
  273. ) or die $dbh->errstr;
  274. my ($id) = $dbh->selectrow_array(
  275. "SELECT id FROM test WHERE filename=? AND product_id=?",
  276. undef, $filename, $self->{_product_id}
  277. );
  278. return $id;
  279. }
  280. sub _ensure_build {
  281. my ( $self, $build ) = @_;
  282. my $dbh = $self->{dbh};
  283. $dbh->do(
  284. "INSERT OR IGNORE INTO build (build, product_id) VALUES (?, ?)",
  285. undef, $build, $self->{_product_id}
  286. ) or die $dbh->errstr;
  287. my ($id) = $dbh->selectrow_array(
  288. "SELECT id FROM build WHERE build=? AND product_id=?",
  289. undef, $build, $self->{_product_id}
  290. );
  291. return $id;
  292. }
  293. sub _build_id {
  294. my ( $self, $build ) = @_;
  295. my ($id) = $self->{dbh}->selectrow_array(
  296. "SELECT id FROM build WHERE build=? AND product_id=?",
  297. undef, $build, $self->{_product_id}
  298. );
  299. return $id;
  300. }
  301. sub _test_id {
  302. my ( $self, $filename ) = @_;
  303. my ($id) = $self->{dbh}->selectrow_array(
  304. "SELECT id FROM test WHERE filename=? AND product_id=?",
  305. undef, $filename, $self->{_product_id}
  306. );
  307. return $id;
  308. }
  309. sub _ensure_resource {
  310. my ( $self, $filename ) = @_;
  311. my $dbh = $self->{dbh};
  312. $dbh->do(
  313. "INSERT OR IGNORE INTO resource (environment_id, filename) VALUES (?, ?)",
  314. undef, $self->{_env_id}, $filename
  315. ) or die $dbh->errstr;
  316. my ($id) = $dbh->selectrow_array(
  317. "SELECT id FROM resource WHERE environment_id=? AND filename=?",
  318. undef, $self->{_env_id}, $filename
  319. );
  320. return $id;
  321. }
  322. 1;