File Coverage

blib/lib/Test/TCM/Role/API.pm
Criterion Covered Total %
statement 32 90 35.5
branch 0 16 0.0
condition 0 2 0.0
subroutine 11 20 55.0
pod 1 1 100.0
total 44 129 34.1


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