File Coverage

blib/lib/Test/TCM/Role/API.pm
Criterion Covered Total %
statement 32 93 34.4
branch 0 16 0.0
condition 0 2 0.0
subroutine 11 20 55.0
pod 1 1 100.0
total 44 132 33.3


line stmt bran cond sub pod time code
1             package Test::TCM::Role::API;
2              
3             =head1 NAME
4              
5             Test::TCM::Role::API - Role to test PSGI-based JSON API using
6             L<Test::Class::Moose>.
7              
8             =head1 SYNOPSIS
9              
10             package TestsFor::MyApp::Controller::API::v1::Some::Thing
11              
12             use Test::Class::Moose;
13             with qw(
14             Test::TCM::Role::API
15             );
16              
17             sub _api_route_prefix { '/api/v1' }
18              
19             sub test_some_route ($test, $) {
20              
21             # Calls "GET /api/v1/character"
22             $test->api_ok(
23             'List characters',
24             [GET => '/character'],
25             {
26             status => HTTP_OK,
27             json_content => {
28             superhashof(
29             {
30             attributes =>
31             { map { $_ => ignore() } qw(id name created) },
32             }
33             )
34             },
35             }
36             );
37              
38             my $result = $test->api_ok(
39             'Create character',
40             [
41             POST => '/character' => {
42             name => 'Player 1',
43             user_id => 12345,
44             }
45             ],
46             {
47             status => HTTP_OK,
48             json_content => {
49             success => 1,
50             character_id => ignore(),
51             },
52             }
53             );
54             is( $result->{id},
55             $test->fetch_last_character()->id,
56             'Result has a proper character ID'
57             );
58             }
59              
60             =cut
61              
62 1     1   1244 use Moose::Role;
  1         475117  
  1         6  
63              
64 1     1   5977 use v5.20;
  1         4  
65 1     1   7 use warnings;
  1         4  
  1         36  
66 1     1   860 use experimental qw(smartmatch signatures);
  1         3715  
  1         6  
67              
68 1     1   213 use Carp qw(croak);
  1         3  
  1         44  
69 1     1   575 use HTTP::Request;
  1         21883  
  1         37  
70 1     1   807 use JSON qw(encode_json decode_json);
  1         8545  
  1         7  
71 1     1   603 use Plack::Test;
  1         517  
  1         67  
72 1     1   666 use Test::Deep qw(cmp_deeply);
  1         9625  
  1         6  
73 1     1   801 use Test::Differences qw(eq_or_diff);
  1         18204  
  1         69  
74 1     1   9 use Test::More;
  1         2  
  1         15  
75              
76             our $VERSION = 0.05;
77              
78             =head1 REQUIRED METHODS
79              
80             =head2 psgi_app
81              
82             PSGI application we're testing.
83              
84             =cut
85              
86             requires 'psgi_app';
87              
88             =head1 ATTRIBUTES
89              
90             =head2 api_client
91              
92             PSGI-compatible API client to use. Built automatically using C<psgi_app> method.
93              
94             =cut
95              
96             has 'api_client' => (
97             is => 'ro',
98             isa => 'Object',
99             lazy => 1,
100             builder => '_build_api_client',
101             );
102              
103             sub _build_api_client {
104 0     0     my ($test) = @_;
105 0           return Plack::Test->create($test->psgi_app);
106             }
107              
108             after test_setup => sub ( $test, $ ) {
109             $test->api_client->add_header( $test->_api_headers );
110             };
111              
112             =head1 PRIVATE METHODS THAT CAN BE OVERRIDDEN
113              
114             =head2 _api_content_type
115              
116             Returns content type for this API, default: C<application/vnd.api+json>.
117              
118             =cut
119              
120 0     0     sub _api_content_type {'application/vnd.api+json'}
121              
122             =head2 _api_headers
123              
124             Returns a hash of headers to add to C<< $test->mech >>, defaults to
125             C<< ( Accept => _api_content_type() ) >>
126              
127             =cut
128              
129             sub _api_headers {
130 0     0     return ( 'Accept' => _api_content_type() );
131             }
132              
133             =head2 _api_route_prefix
134              
135             Common prefix for all API requests. Defaults to the empty string.
136              
137             =cut
138              
139 0     0     sub _api_route_prefix {''}
140              
141             =head2 _before_request_hook($request)
142              
143             Method that is called right before request is made. Gets a complete
144             HTTP::Request object as the only argument. You can inspect / modify this
145             request as needed - e.g. to add additional authorization headers to it.
146              
147             =head1 METHODS
148              
149             =head2 api_ok($title, \@request_args, \%expected)
150              
151             In: $title - (sub)test title
152             \@request_args - request data, 3-elements array of:
153             $method - HTTP method
154             $route - route to call
155             \%params - URL query params (for GET) or JSON data (for other
156             request types)
157             \%expected - hash of expected parameters with the following fields
158             status - HTTP status code; defaults to any successful code
159             json_content - reference to a structure we expect, to be passed to
160             C<Test::Deep::cmp_deeply> (so C<Test::Deep>'s functions can be used
161             to skip / ignore some methods in it).
162             Out: $json_content - if result was a valid JSON, otherwise undef
163              
164             Perform API C<$method> request on the C<$route> and test its output against
165             C<%expected> values.
166              
167             If C<_api_route_prefix()> is implemented in the consuming class, the value it
168             returns gets prepended to the route before request is performed.
169              
170             =cut
171              
172 0     0 1   sub api_ok ( $test, $title, $request_args, $expected ) {
  0            
  0            
  0            
  0            
  0            
173 0           local $Test::Builder::Level = $Test::Builder::Level + 1;
174              
175 0           $test->_perform_request($test->_generate_request(@$request_args));
176 0           return $test->_process_test_results( $title, $expected );
177             }
178              
179 0     0     sub _generate_request ($test, $method, $route, $params = undef) {
  0            
  0            
  0            
  0            
  0            
180 0 0 0       if ( my $route_prefix = $test->_api_route_prefix // '' ) {
181 0           $route = $route_prefix . $route;
182             }
183              
184 0           my $request = HTTP::Request->new( $method => $route );
185 0           $request->header( map { $_ => _api_content_type() }
  0            
186             qw(Accept Content-Type) );
187 0           given ($method) {
188 0           when ( [qw(GET DELETE)] ) {
189 0 0         if ($params) {
190 0           $request->uri->query_form($params);
191             }
192             }
193 0           when ( [qw(PATCH POST PUT)] ) {
194 0 0         if ($params) {
195 0           $request->content( encode_json($params) );
196             }
197             }
198 0           default {
199 0           croak "Don't know such request method as '$method'";
200             }
201             }
202              
203 0           return $request;
204             }
205              
206 0     0     sub _perform_request ($test, $request) {
  0            
  0            
  0            
207 0 0         if ($test->can('_before_request_hook')) {
208 0           $test->_before_request_hook($request);
209             }
210 0           $test->api_client->request($request);
211             }
212              
213 0     0     sub _process_test_results ( $test, $title, $expected ) {
  0            
  0            
  0            
  0            
214 0           local $Test::Builder::Level = $Test::Builder::Level + 1;
215              
216 0           my $json_content;
217             subtest $title => sub {
218              
219 0 0   0     if ( exists $expected->{status} ) {
220             is( $test->api_client->status, $expected->{status},
221 0           "Status is as expected ($expected->{status})"
222             );
223             }
224             else {
225 0           like(
226             $test->api_client->status, qr/^2\d{2}$/,
227             'Status is success'
228             );
229             }
230              
231 0 0         if ( exists $expected->{json_content} ) {
232 0           $json_content = eval { decode_json($test->api_client->content); }
233 0 0         or do {
234 0           fail("We've got a proper JSON response");
235 0           diag('Got: ' . $test->api_client->response->as_string);
236 0           return;
237             };
238              
239             # eq_or_diff() is only used to output diagnostics in case of a
240             # test failure.
241             cmp_deeply(
242             $json_content,
243             $expected->{json_content},
244             'Data is as expected'
245 0 0         ) or eq_or_diff($json_content, $expected->{json_content});
246             }
247 0           };
248 0           return $json_content;
249             }
250              
251             =head1 AUTHOR
252              
253             Ilya Chesnokov L<chesnokov@cpan.org>.
254              
255             =head1 LICENSE
256              
257             Under the same terms as Perl itself.
258              
259             =head1 CREDITS
260              
261             Many thanks to the following people and organizations:
262              
263             =over
264              
265             =item Sam Kington L<cpan@illuminated.co.uk>
266              
267             For the idea and the initial implementation.
268              
269             =item All Around the World SASU L<https://allaroundtheworld.fr>
270              
271             For sponsoring this rewrite and publication.
272              
273             =back
274              
275             =cut
276              
277             1;