api_matchers 0.6.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +30 -0
- data/.gitignore +1 -1
- data/Gemfile +1 -1
- data/History.markdown +62 -46
- data/README.markdown +1070 -114
- data/TODO.markdown +39 -3
- data/api_matchers.gemspec +13 -6
- data/lib/api_matchers/collection/base.rb +65 -0
- data/lib/api_matchers/collection/be_sorted_by.rb +97 -0
- data/lib/api_matchers/collection/have_json_size.rb +54 -0
- data/lib/api_matchers/core/exceptions.rb +5 -0
- data/lib/api_matchers/core/find_in_json.rb +57 -28
- data/lib/api_matchers/core/http_status_codes.rb +118 -0
- data/lib/api_matchers/core/json_path_finder.rb +87 -0
- data/lib/api_matchers/core/parser.rb +4 -2
- data/lib/api_matchers/core/rspec_matchers.rb +130 -37
- data/lib/api_matchers/core/setup.rb +49 -23
- data/lib/api_matchers/core/value_normalizer.rb +26 -0
- data/lib/api_matchers/error_response/base.rb +77 -0
- data/lib/api_matchers/error_response/have_error.rb +69 -0
- data/lib/api_matchers/error_response/have_error_on.rb +173 -0
- data/lib/api_matchers/hateoas/base.rb +77 -0
- data/lib/api_matchers/hateoas/have_link.rb +102 -0
- data/lib/api_matchers/headers/base.rb +7 -7
- data/lib/api_matchers/headers/be_json.rb +2 -4
- data/lib/api_matchers/headers/be_xml.rb +2 -4
- data/lib/api_matchers/headers/have_cache_control.rb +90 -0
- data/lib/api_matchers/headers/have_cors_headers.rb +102 -0
- data/lib/api_matchers/headers/have_header.rb +102 -0
- data/lib/api_matchers/http_status/base.rb +48 -0
- data/lib/api_matchers/http_status/be_client_error.rb +17 -0
- data/lib/api_matchers/http_status/be_forbidden.rb +17 -0
- data/lib/api_matchers/http_status/be_no_content.rb +17 -0
- data/lib/api_matchers/http_status/be_not_found.rb +17 -0
- data/lib/api_matchers/http_status/be_redirect.rb +17 -0
- data/lib/api_matchers/http_status/be_server_error.rb +17 -0
- data/lib/api_matchers/http_status/be_successful.rb +17 -0
- data/lib/api_matchers/http_status/be_unauthorized.rb +17 -0
- data/lib/api_matchers/http_status/be_unprocessable.rb +17 -0
- data/lib/api_matchers/http_status/have_http_status.rb +39 -0
- data/lib/api_matchers/json_api/base.rb +83 -0
- data/lib/api_matchers/json_api/be_json_api_compliant.rb +158 -0
- data/lib/api_matchers/json_api/have_json_api_attributes.rb +62 -0
- data/lib/api_matchers/json_api/have_json_api_data.rb +110 -0
- data/lib/api_matchers/json_api/have_json_api_relationships.rb +62 -0
- data/lib/api_matchers/json_structure/base.rb +65 -0
- data/lib/api_matchers/json_structure/have_json_keys.rb +55 -0
- data/lib/api_matchers/json_structure/have_json_type.rb +72 -0
- data/lib/api_matchers/pagination/base.rb +73 -0
- data/lib/api_matchers/pagination/be_paginated.rb +73 -0
- data/lib/api_matchers/pagination/have_pagination_links.rb +74 -0
- data/lib/api_matchers/pagination/have_total_count.rb +77 -0
- data/lib/api_matchers/response_body/base.rb +10 -9
- data/lib/api_matchers/response_body/have_json.rb +2 -4
- data/lib/api_matchers/response_body/have_json_node.rb +45 -1
- data/lib/api_matchers/response_body/have_node.rb +2 -0
- data/lib/api_matchers/response_body/have_xml_node.rb +13 -14
- data/lib/api_matchers/response_body/match_json_schema.rb +89 -0
- data/lib/api_matchers/version.rb +3 -1
- data/lib/api_matchers.rb +75 -14
- data/spec/api_matchers/collection/be_sorted_by_spec.rb +110 -0
- data/spec/api_matchers/collection/have_json_size_spec.rb +101 -0
- data/spec/api_matchers/error_response/have_error_on_spec.rb +123 -0
- data/spec/api_matchers/error_response/have_error_spec.rb +108 -0
- data/spec/api_matchers/hateoas/have_link_spec.rb +105 -0
- data/spec/api_matchers/headers/base_spec.rb +8 -3
- data/spec/api_matchers/headers/be_json_spec.rb +1 -1
- data/spec/api_matchers/headers/be_xml_spec.rb +1 -1
- data/spec/api_matchers/headers/have_cache_control_spec.rb +102 -0
- data/spec/api_matchers/headers/have_cors_headers_spec.rb +74 -0
- data/spec/api_matchers/headers/have_header_spec.rb +88 -0
- data/spec/api_matchers/http_status/be_client_error_spec.rb +53 -0
- data/spec/api_matchers/http_status/be_forbidden_spec.rb +33 -0
- data/spec/api_matchers/http_status/be_no_content_spec.rb +33 -0
- data/spec/api_matchers/http_status/be_not_found_spec.rb +39 -0
- data/spec/api_matchers/http_status/be_redirect_spec.rb +55 -0
- data/spec/api_matchers/http_status/be_server_error_spec.rb +49 -0
- data/spec/api_matchers/http_status/be_successful_spec.rb +78 -0
- data/spec/api_matchers/http_status/be_unauthorized_spec.rb +33 -0
- data/spec/api_matchers/http_status/be_unprocessable_spec.rb +39 -0
- data/spec/api_matchers/http_status/have_http_status_spec.rb +81 -0
- data/spec/api_matchers/json_api/be_json_api_compliant_spec.rb +109 -0
- data/spec/api_matchers/json_api/have_json_api_attributes_spec.rb +61 -0
- data/spec/api_matchers/json_api/have_json_api_data_spec.rb +95 -0
- data/spec/api_matchers/json_api/have_json_api_relationships_spec.rb +61 -0
- data/spec/api_matchers/json_structure/have_json_keys_spec.rb +81 -0
- data/spec/api_matchers/json_structure/have_json_type_spec.rb +134 -0
- data/spec/api_matchers/pagination/be_paginated_spec.rb +95 -0
- data/spec/api_matchers/pagination/have_pagination_links_spec.rb +80 -0
- data/spec/api_matchers/pagination/have_total_count_spec.rb +85 -0
- data/spec/api_matchers/response_body/base_spec.rb +15 -7
- data/spec/api_matchers/response_body/have_json_node_spec.rb +57 -0
- data/spec/api_matchers/response_body/match_json_schema_spec.rb +86 -0
- metadata +154 -48
- data/.rvmrc.example +0 -1
- data/.travis.yml +0 -12
- data/lib/api_matchers/http_status_code/base.rb +0 -32
- data/lib/api_matchers/http_status_code/be_bad_request.rb +0 -25
- data/lib/api_matchers/http_status_code/be_forbidden.rb +0 -21
- data/lib/api_matchers/http_status_code/be_internal_server_error.rb +0 -25
- data/lib/api_matchers/http_status_code/be_not_found.rb +0 -25
- data/lib/api_matchers/http_status_code/be_ok.rb +0 -25
- data/lib/api_matchers/http_status_code/be_unauthorized.rb +0 -25
- data/lib/api_matchers/http_status_code/be_unprocessable_entity.rb +0 -25
- data/lib/api_matchers/http_status_code/create_resource.rb +0 -25
- data/spec/api_matchers/http_status_code/base_spec.rb +0 -12
- data/spec/api_matchers/http_status_code/be_bad_request_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_forbidden_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_internal_server_error_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_not_found_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_ok_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_unauthorized_spec.rb +0 -49
- data/spec/api_matchers/http_status_code/be_unprocessable_entity_spec.rb +0 -27
- data/spec/api_matchers/http_status_code/create_resource_spec.rb +0 -49
data/README.markdown
CHANGED
|
@@ -1,269 +1,1225 @@
|
|
|
1
|
-
# API Matchers [](https://github.com/tomas-stefano/api_matchers/actions/workflows/ci.yml) [](https://badge.fury.io/rb/api_matchers)
|
|
2
2
|
|
|
3
3
|
Collection of RSpec matchers for your API.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Requirements](#requirements)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Quick Start](#quick-start)
|
|
10
|
+
- [Matchers](#matchers)
|
|
11
|
+
- [Response Body Matchers](#response-body-matchers)
|
|
12
|
+
- [have_json_node](#have_json_node)
|
|
13
|
+
- [have_xml_node](#have_xml_node)
|
|
14
|
+
- [have_node](#have_node)
|
|
15
|
+
- [have_json](#have_json)
|
|
16
|
+
- [match_json_schema](#match_json_schema)
|
|
17
|
+
- [HTTP Status Matchers](#http-status-matchers)
|
|
18
|
+
- [have_http_status](#have_http_status)
|
|
19
|
+
- [be_successful](#be_successful)
|
|
20
|
+
- [be_redirect](#be_redirect)
|
|
21
|
+
- [be_client_error / be_server_error](#be_client_error--be_server_error)
|
|
22
|
+
- [Specific Status Matchers](#specific-status-matchers)
|
|
23
|
+
- [JSON Structure Matchers](#json-structure-matchers)
|
|
24
|
+
- [have_json_keys](#have_json_keys)
|
|
25
|
+
- [have_json_type](#have_json_type)
|
|
26
|
+
- [Collection Matchers](#collection-matchers)
|
|
27
|
+
- [have_json_size](#have_json_size)
|
|
28
|
+
- [be_sorted_by](#be_sorted_by)
|
|
29
|
+
- [Header Matchers](#header-matchers)
|
|
30
|
+
- [be_json / be_xml](#be_json--be_xml)
|
|
31
|
+
- [have_header](#have_header)
|
|
32
|
+
- [have_cors_headers](#have_cors_headers)
|
|
33
|
+
- [have_cache_control](#have_cache_control)
|
|
34
|
+
- [Pagination Matchers](#pagination-matchers)
|
|
35
|
+
- [be_paginated](#be_paginated)
|
|
36
|
+
- [have_pagination_links](#have_pagination_links)
|
|
37
|
+
- [have_total_count](#have_total_count)
|
|
38
|
+
- [Error Response Matchers](#error-response-matchers)
|
|
39
|
+
- [have_error / have_errors](#have_error--have_errors)
|
|
40
|
+
- [have_error_on](#have_error_on)
|
|
41
|
+
- [JSON:API Matchers](#jsonapi-matchers)
|
|
42
|
+
- [be_json_api_compliant](#be_json_api_compliant)
|
|
43
|
+
- [have_json_api_data](#have_json_api_data)
|
|
44
|
+
- [have_json_api_attributes](#have_json_api_attributes)
|
|
45
|
+
- [have_json_api_relationships](#have_json_api_relationships)
|
|
46
|
+
- [HATEOAS Matchers](#hateoas-matchers)
|
|
47
|
+
- [have_link](#have_link)
|
|
48
|
+
- [Configuration](#configuration)
|
|
49
|
+
- [Upgrading from 0.x to 1.0](#upgrading-from-0x-to-10)
|
|
50
|
+
- [Contributing](#contributing)
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Ruby 3.1+
|
|
55
|
+
- RSpec 3.12+
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
Add to your Gemfile:
|
|
6
60
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
61
|
+
```ruby
|
|
62
|
+
group :test do
|
|
63
|
+
gem 'api_matchers'
|
|
64
|
+
|
|
65
|
+
# Optional: for JSON Schema validation
|
|
66
|
+
gem 'json_schemer'
|
|
67
|
+
end
|
|
68
|
+
```
|
|
11
69
|
|
|
12
|
-
|
|
70
|
+
Or install manually:
|
|
13
71
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* `be_unauthorized`
|
|
18
|
-
* `be_forbidden`
|
|
19
|
-
* `be_internal_server_error`
|
|
20
|
-
* `be_not_found`
|
|
72
|
+
```bash
|
|
73
|
+
gem install api_matchers
|
|
74
|
+
```
|
|
21
75
|
|
|
22
|
-
##
|
|
76
|
+
## Quick Start
|
|
23
77
|
|
|
24
|
-
|
|
25
|
-
* `be_in_json`
|
|
78
|
+
Include the matchers in your RSpec configuration:
|
|
26
79
|
|
|
27
|
-
|
|
80
|
+
```ruby
|
|
81
|
+
# spec/spec_helper.rb or spec/rails_helper.rb
|
|
82
|
+
RSpec.configure do |config|
|
|
83
|
+
config.include APIMatchers::RSpecMatchers
|
|
84
|
+
end
|
|
85
|
+
```
|
|
28
86
|
|
|
29
|
-
|
|
87
|
+
Then use them in your specs:
|
|
30
88
|
|
|
31
89
|
```ruby
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
90
|
+
RSpec.describe "Users API" do
|
|
91
|
+
it "returns user data" do
|
|
92
|
+
get "/api/users/1"
|
|
93
|
+
|
|
94
|
+
expect(response.body).to have_json_node(:id).with(1)
|
|
95
|
+
expect(response.body).to have_json_node(:name).with("John")
|
|
96
|
+
expect(response.body).to have_json_node(:email).including_text("@example.com")
|
|
97
|
+
end
|
|
35
98
|
end
|
|
36
99
|
```
|
|
37
100
|
|
|
38
|
-
|
|
101
|
+
## Matchers
|
|
39
102
|
|
|
40
|
-
|
|
103
|
+
### Response Body Matchers
|
|
41
104
|
|
|
42
|
-
###
|
|
105
|
+
### have_json_node
|
|
43
106
|
|
|
44
|
-
|
|
107
|
+
Verifies the presence of a node in JSON, with optional value matching.
|
|
108
|
+
|
|
109
|
+
#### Basic Usage
|
|
45
110
|
|
|
46
111
|
```ruby
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
112
|
+
json = '{"user": {"id": 1, "name": "John", "active": true}}'
|
|
113
|
+
|
|
114
|
+
# Check node exists
|
|
115
|
+
expect(json).to have_json_node(:user)
|
|
116
|
+
expect(json).to have_json_node(:id) # Works with nested nodes too
|
|
117
|
+
|
|
118
|
+
# Check node exists with specific value
|
|
119
|
+
expect(json).to have_json_node(:id).with(1)
|
|
120
|
+
expect(json).to have_json_node(:name).with("John")
|
|
121
|
+
expect(json).to have_json_node(:active).with(true)
|
|
122
|
+
|
|
123
|
+
# Check node does NOT exist
|
|
124
|
+
expect(json).not_to have_json_node(:admin)
|
|
125
|
+
|
|
126
|
+
# Check node exists but with different value
|
|
127
|
+
expect(json).not_to have_json_node(:id).with(999)
|
|
50
128
|
```
|
|
51
129
|
|
|
52
|
-
|
|
130
|
+
#### Deeply Nested JSON
|
|
53
131
|
|
|
54
|
-
|
|
55
|
-
|
|
132
|
+
```ruby
|
|
133
|
+
json = '{
|
|
134
|
+
"response": {
|
|
135
|
+
"data": {
|
|
136
|
+
"transaction": {
|
|
137
|
+
"id": 12345,
|
|
138
|
+
"status": "completed",
|
|
139
|
+
"payment": {
|
|
140
|
+
"method": "credit_card",
|
|
141
|
+
"amount": 99.99
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}'
|
|
147
|
+
|
|
148
|
+
# All these work - it searches recursively
|
|
149
|
+
expect(json).to have_json_node(:transaction)
|
|
150
|
+
expect(json).to have_json_node(:id).with(12345)
|
|
151
|
+
expect(json).to have_json_node(:status).with("completed")
|
|
152
|
+
expect(json).to have_json_node(:method).with("credit_card")
|
|
153
|
+
expect(json).to have_json_node(:amount).with(99.99)
|
|
154
|
+
```
|
|
56
155
|
|
|
57
|
-
|
|
156
|
+
#### Different Value Types
|
|
58
157
|
|
|
59
158
|
```ruby
|
|
60
|
-
|
|
159
|
+
# Strings
|
|
160
|
+
expect('{"name": "Alice"}').to have_json_node(:name).with("Alice")
|
|
161
|
+
|
|
162
|
+
# Integers
|
|
163
|
+
expect('{"count": 42}').to have_json_node(:count).with(42)
|
|
164
|
+
|
|
165
|
+
# Floats
|
|
166
|
+
expect('{"price": 19.99}').to have_json_node(:price).with(19.99)
|
|
167
|
+
|
|
168
|
+
# Booleans
|
|
169
|
+
expect('{"enabled": true}').to have_json_node(:enabled).with(true)
|
|
170
|
+
expect('{"disabled": false}').to have_json_node(:disabled).with(false)
|
|
171
|
+
|
|
172
|
+
# Null
|
|
173
|
+
expect('{"deleted_at": null}').to have_json_node(:deleted_at).with(nil)
|
|
174
|
+
|
|
175
|
+
# Empty string
|
|
176
|
+
expect('{"nickname": ""}').to have_json_node(:nickname).with("")
|
|
177
|
+
|
|
178
|
+
# Empty array
|
|
179
|
+
expect('{"items": []}').to have_json_node(:items).with([])
|
|
180
|
+
|
|
181
|
+
# Empty object
|
|
182
|
+
expect('{"metadata": {}}').to have_json_node(:metadata).with({})
|
|
61
183
|
```
|
|
62
184
|
|
|
63
|
-
|
|
185
|
+
#### Partial Text Matching
|
|
186
|
+
|
|
187
|
+
Use `including_text` to check if a node's value contains specific text:
|
|
64
188
|
|
|
65
189
|
```ruby
|
|
66
|
-
|
|
190
|
+
json = '{"error": "Validation failed: Email is invalid, Name is too short"}'
|
|
191
|
+
|
|
192
|
+
expect(json).to have_json_node(:error).including_text("Email is invalid")
|
|
193
|
+
expect(json).to have_json_node(:error).including_text("Validation failed")
|
|
194
|
+
|
|
195
|
+
# Useful for URLs
|
|
196
|
+
json = '{"avatar_url": "https://cdn.example.com/users/123/avatar.png"}'
|
|
197
|
+
expect(json).to have_json_node(:avatar_url).including_text("cdn.example.com")
|
|
198
|
+
expect(json).to have_json_node(:avatar_url).including_text("/users/123/")
|
|
199
|
+
|
|
200
|
+
# Useful for timestamps (partial match)
|
|
201
|
+
json = '{"created_at": "2024-01-15T10:30:00Z"}'
|
|
202
|
+
expect(json).to have_json_node(:created_at).including_text("2024-01-15")
|
|
203
|
+
|
|
204
|
+
# Useful for generated IDs or tokens
|
|
205
|
+
json = '{"session_id": "sess_abc123xyz789"}'
|
|
206
|
+
expect(json).to have_json_node(:session_id).including_text("sess_")
|
|
207
|
+
|
|
208
|
+
# Negation
|
|
209
|
+
expect(json).not_to have_json_node(:error).including_text("Server error")
|
|
67
210
|
```
|
|
68
211
|
|
|
212
|
+
#### Array Matching
|
|
213
|
+
|
|
214
|
+
##### `including` - Check if array contains an element
|
|
215
|
+
|
|
69
216
|
```ruby
|
|
70
|
-
|
|
217
|
+
json = '{"users": [{"name": "Alice", "role": "admin"}, {"name": "Bob", "role": "user"}]}'
|
|
218
|
+
|
|
219
|
+
# Match by single attribute
|
|
220
|
+
expect(json).to have_json_node(:users).including(name: "Alice")
|
|
221
|
+
|
|
222
|
+
# Match by multiple attributes
|
|
223
|
+
expect(json).to have_json_node(:users).including(name: "Alice", role: "admin")
|
|
224
|
+
|
|
225
|
+
# Negation - array does NOT contain element
|
|
226
|
+
expect(json).not_to have_json_node(:users).including(name: "Charlie")
|
|
227
|
+
expect(json).not_to have_json_node(:users).including(role: "superadmin")
|
|
228
|
+
|
|
229
|
+
# Works with simple arrays too
|
|
230
|
+
json = '{"tags": ["ruby", "rails", "api"]}'
|
|
231
|
+
expect(json).to have_json_node(:tags).including("ruby")
|
|
232
|
+
expect(json).not_to have_json_node(:tags).including("python")
|
|
233
|
+
|
|
234
|
+
# Works with numbers
|
|
235
|
+
json = '{"scores": [85, 92, 78, 95]}'
|
|
236
|
+
expect(json).to have_json_node(:scores).including(92)
|
|
237
|
+
|
|
238
|
+
# Practical API example - check if a specific item exists in results
|
|
239
|
+
json = '{
|
|
240
|
+
"products": [
|
|
241
|
+
{"id": 1, "name": "Laptop", "in_stock": true},
|
|
242
|
+
{"id": 2, "name": "Phone", "in_stock": false},
|
|
243
|
+
{"id": 3, "name": "Tablet", "in_stock": true}
|
|
244
|
+
]
|
|
245
|
+
}'
|
|
246
|
+
expect(json).to have_json_node(:products).including(id: 2, name: "Phone")
|
|
247
|
+
expect(json).to have_json_node(:products).including(in_stock: true)
|
|
71
248
|
```
|
|
72
249
|
|
|
250
|
+
##### `including_all` - Check if array contains all specified elements
|
|
251
|
+
|
|
73
252
|
```ruby
|
|
74
|
-
|
|
253
|
+
json = '{"permissions": [{"action": "read"}, {"action": "write"}, {"action": "delete"}]}'
|
|
254
|
+
|
|
255
|
+
# All must be present
|
|
256
|
+
expect(json).to have_json_node(:permissions).including_all([
|
|
257
|
+
{action: "read"},
|
|
258
|
+
{action: "write"}
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
# Fails if any element is missing
|
|
262
|
+
expect(json).not_to have_json_node(:permissions).including_all([
|
|
263
|
+
{action: "read"},
|
|
264
|
+
{action: "execute"} # This doesn't exist
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
# Simple values
|
|
268
|
+
json = '{"ids": [1, 2, 3, 4, 5]}'
|
|
269
|
+
expect(json).to have_json_node(:ids).including_all([1, 3, 5])
|
|
270
|
+
expect(json).not_to have_json_node(:ids).including_all([1, 6, 7])
|
|
271
|
+
|
|
272
|
+
# Strings
|
|
273
|
+
json = '{"features": ["dark_mode", "notifications", "export", "api_access"]}'
|
|
274
|
+
expect(json).to have_json_node(:features).including_all(["dark_mode", "api_access"])
|
|
275
|
+
|
|
276
|
+
# Practical example - verify required fields in response
|
|
277
|
+
json = '{
|
|
278
|
+
"user": {
|
|
279
|
+
"roles": [
|
|
280
|
+
{"name": "viewer", "level": 1},
|
|
281
|
+
{"name": "editor", "level": 2},
|
|
282
|
+
{"name": "admin", "level": 3}
|
|
283
|
+
]
|
|
284
|
+
}
|
|
285
|
+
}'
|
|
286
|
+
expect(json).to have_json_node(:roles).including_all([
|
|
287
|
+
{name: "viewer"},
|
|
288
|
+
{name: "admin"}
|
|
289
|
+
])
|
|
290
|
+
|
|
291
|
+
# Verify order doesn't matter
|
|
292
|
+
json = '{"steps": ["init", "validate", "process", "complete"]}'
|
|
293
|
+
expect(json).to have_json_node(:steps).including_all(["complete", "init"]) # Different order, still passes
|
|
75
294
|
```
|
|
76
295
|
|
|
77
|
-
|
|
296
|
+
#### Date and Time Values
|
|
297
|
+
|
|
298
|
+
The matcher automatically handles Date, DateTime, and Time comparisons:
|
|
78
299
|
|
|
79
300
|
```ruby
|
|
80
|
-
|
|
301
|
+
json = '{"created_at": "2024-01-15", "updated_at": "2024-01-15T10:30:00+00:00"}'
|
|
302
|
+
|
|
303
|
+
expect(json).to have_json_node(:created_at).with(Date.parse("2024-01-15"))
|
|
304
|
+
expect(json).to have_json_node(:updated_at).with(DateTime.parse("2024-01-15T10:30:00+00:00"))
|
|
81
305
|
```
|
|
82
306
|
|
|
83
|
-
|
|
307
|
+
#### Null Values
|
|
84
308
|
|
|
85
309
|
```ruby
|
|
86
|
-
|
|
310
|
+
json = '{"middle_name": null}'
|
|
311
|
+
|
|
312
|
+
expect(json).to have_json_node(:middle_name) # Node exists (even if null)
|
|
313
|
+
expect(json).to have_json_node(:middle_name).with(nil) # Explicitly check for null
|
|
87
314
|
```
|
|
88
315
|
|
|
89
|
-
###
|
|
316
|
+
### have_xml_node
|
|
90
317
|
|
|
91
|
-
|
|
318
|
+
Same API as `have_json_node`, but for XML:
|
|
92
319
|
|
|
93
320
|
```ruby
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
321
|
+
xml = '<user><id>1</id><name>John</name></user>'
|
|
322
|
+
|
|
323
|
+
expect(xml).to have_xml_node(:id).with("1")
|
|
324
|
+
expect(xml).to have_xml_node(:name).with("John")
|
|
325
|
+
expect(xml).to have_xml_node(:name).including_text("Jo")
|
|
326
|
+
expect(xml).not_to have_xml_node(:email)
|
|
97
327
|
```
|
|
98
328
|
|
|
329
|
+
#### Nested XML
|
|
330
|
+
|
|
99
331
|
```ruby
|
|
100
|
-
|
|
332
|
+
xml = '
|
|
333
|
+
<response>
|
|
334
|
+
<status>success</status>
|
|
335
|
+
<data>
|
|
336
|
+
<user>
|
|
337
|
+
<id>123</id>
|
|
338
|
+
<profile>
|
|
339
|
+
<first_name>John</first_name>
|
|
340
|
+
<last_name>Doe</last_name>
|
|
341
|
+
</profile>
|
|
342
|
+
</user>
|
|
343
|
+
</data>
|
|
344
|
+
</response>
|
|
345
|
+
'
|
|
346
|
+
|
|
347
|
+
expect(xml).to have_xml_node(:status).with("success")
|
|
348
|
+
expect(xml).to have_xml_node(:id).with("123")
|
|
349
|
+
expect(xml).to have_xml_node(:first_name).with("John")
|
|
350
|
+
expect(xml).to have_xml_node(:last_name).with("Doe")
|
|
101
351
|
```
|
|
102
352
|
|
|
103
|
-
|
|
353
|
+
#### XML with Attributes
|
|
104
354
|
|
|
105
355
|
```ruby
|
|
106
|
-
|
|
356
|
+
xml = '<product id="456" status="active"><name>Widget</name><price currency="USD">29.99</price></product>'
|
|
357
|
+
|
|
358
|
+
# Check element content
|
|
359
|
+
expect(xml).to have_xml_node(:name).with("Widget")
|
|
360
|
+
expect(xml).to have_xml_node(:price).with("29.99")
|
|
361
|
+
|
|
362
|
+
# Check element exists
|
|
363
|
+
expect(xml).to have_xml_node(:product)
|
|
364
|
+
expect(xml).to have_xml_node(:price)
|
|
107
365
|
```
|
|
108
366
|
|
|
109
|
-
|
|
367
|
+
#### Partial Text in XML
|
|
110
368
|
|
|
111
369
|
```ruby
|
|
112
|
-
|
|
370
|
+
xml = '<error><message>Validation failed: Email format is invalid</message></error>'
|
|
371
|
+
|
|
372
|
+
expect(xml).to have_xml_node(:message).including_text("Validation failed")
|
|
373
|
+
expect(xml).to have_xml_node(:message).including_text("Email format")
|
|
374
|
+
expect(xml).not_to have_xml_node(:message).including_text("Server error")
|
|
113
375
|
```
|
|
114
376
|
|
|
115
|
-
|
|
377
|
+
#### SOAP Response Example
|
|
116
378
|
|
|
117
379
|
```ruby
|
|
118
|
-
|
|
380
|
+
soap_response = '
|
|
381
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
382
|
+
<soap:Body>
|
|
383
|
+
<GetUserResponse>
|
|
384
|
+
<User>
|
|
385
|
+
<Id>42</Id>
|
|
386
|
+
<Username>johndoe</Username>
|
|
387
|
+
<Email>john@example.com</Email>
|
|
388
|
+
</User>
|
|
389
|
+
</GetUserResponse>
|
|
390
|
+
</soap:Body>
|
|
391
|
+
</soap:Envelope>
|
|
392
|
+
'
|
|
393
|
+
|
|
394
|
+
expect(soap_response).to have_xml_node(:Id).with("42")
|
|
395
|
+
expect(soap_response).to have_xml_node(:Username).with("johndoe")
|
|
396
|
+
expect(soap_response).to have_xml_node(:Email).including_text("@example.com")
|
|
119
397
|
```
|
|
120
398
|
|
|
121
|
-
|
|
399
|
+
### have_node
|
|
122
400
|
|
|
123
|
-
|
|
401
|
+
A generic matcher that works with either JSON or XML based on configuration:
|
|
124
402
|
|
|
125
403
|
```ruby
|
|
404
|
+
# Default is JSON
|
|
405
|
+
expect('{"name": "John"}').to have_node(:name).with("John")
|
|
406
|
+
|
|
407
|
+
# Configure for XML
|
|
126
408
|
APIMatchers.setup do |config|
|
|
127
|
-
config.
|
|
409
|
+
config.have_node_matcher = :xml
|
|
128
410
|
end
|
|
129
411
|
|
|
130
|
-
expect(
|
|
412
|
+
expect('<name>John</name>').to have_node(:name).with("John")
|
|
131
413
|
```
|
|
132
414
|
|
|
133
|
-
|
|
415
|
+
**Tip:** If your API uses both JSON and XML, use `have_json_node` and `have_xml_node` explicitly for clarity.
|
|
416
|
+
|
|
417
|
+
### have_json
|
|
418
|
+
|
|
419
|
+
Compare entire JSON structures for exact equality:
|
|
134
420
|
|
|
135
421
|
```ruby
|
|
136
|
-
|
|
422
|
+
# Arrays
|
|
423
|
+
expect('["foo", "bar", "baz"]').to have_json(["foo", "bar", "baz"])
|
|
424
|
+
expect('[1, 2, 3]').to have_json([1, 2, 3])
|
|
425
|
+
|
|
426
|
+
# Objects
|
|
427
|
+
expect('{"a": 1, "b": 2}').to have_json({"a" => 1, "b" => 2})
|
|
428
|
+
|
|
429
|
+
# Nested structures
|
|
430
|
+
expect('{"user": {"name": "John", "age": 30}}').to have_json({
|
|
431
|
+
"user" => {
|
|
432
|
+
"name" => "John",
|
|
433
|
+
"age" => 30
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
# Order matters for arrays
|
|
438
|
+
expect('["a", "b", "c"]').to have_json(["a", "b", "c"])
|
|
439
|
+
expect('["a", "b", "c"]').not_to have_json(["c", "b", "a"])
|
|
440
|
+
|
|
441
|
+
# Negation
|
|
442
|
+
expect('{"status": "ok"}').not_to have_json({"status" => "error"})
|
|
443
|
+
|
|
444
|
+
# Useful for API responses with predictable structure
|
|
445
|
+
expect(response.body).to have_json({
|
|
446
|
+
"success" => true,
|
|
447
|
+
"data" => []
|
|
448
|
+
})
|
|
137
449
|
```
|
|
138
450
|
|
|
139
|
-
###
|
|
451
|
+
### match_json_schema
|
|
452
|
+
|
|
453
|
+
Validate JSON against a [JSON Schema](https://json-schema.org/). Requires the `json_schemer` gem.
|
|
454
|
+
|
|
455
|
+
#### Basic Usage
|
|
140
456
|
|
|
141
457
|
```ruby
|
|
142
|
-
|
|
458
|
+
schema = {
|
|
459
|
+
type: "object",
|
|
460
|
+
required: ["id", "name"],
|
|
461
|
+
properties: {
|
|
462
|
+
id: { type: "integer" },
|
|
463
|
+
name: { type: "string", minLength: 1 },
|
|
464
|
+
email: { type: "string", format: "email" }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# Valid JSON
|
|
469
|
+
expect('{"id": 1, "name": "John"}').to match_json_schema(schema)
|
|
470
|
+
|
|
471
|
+
# Invalid JSON - missing required field
|
|
472
|
+
expect('{"id": 1}').not_to match_json_schema(schema)
|
|
473
|
+
|
|
474
|
+
# Invalid JSON - wrong type
|
|
475
|
+
expect('{"id": "not-an-integer", "name": "John"}').not_to match_json_schema(schema)
|
|
143
476
|
```
|
|
144
477
|
|
|
145
|
-
|
|
478
|
+
#### Complex Schemas
|
|
146
479
|
|
|
147
480
|
```ruby
|
|
148
|
-
|
|
481
|
+
schema = {
|
|
482
|
+
type: "object",
|
|
483
|
+
required: ["data"],
|
|
484
|
+
properties: {
|
|
485
|
+
data: {
|
|
486
|
+
type: "array",
|
|
487
|
+
items: {
|
|
488
|
+
type: "object",
|
|
489
|
+
required: ["id", "type"],
|
|
490
|
+
properties: {
|
|
491
|
+
id: { type: "integer" },
|
|
492
|
+
type: { type: "string", enum: ["user", "admin"] },
|
|
493
|
+
attributes: {
|
|
494
|
+
type: "object",
|
|
495
|
+
properties: {
|
|
496
|
+
name: { type: "string" },
|
|
497
|
+
created_at: { type: "string", format: "date-time" }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
meta: {
|
|
504
|
+
type: "object",
|
|
505
|
+
properties: {
|
|
506
|
+
total: { type: "integer" },
|
|
507
|
+
page: { type: "integer" }
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
json = {
|
|
514
|
+
data: [
|
|
515
|
+
{ id: 1, type: "user", attributes: { name: "John" } },
|
|
516
|
+
{ id: 2, type: "admin", attributes: { name: "Jane" } }
|
|
517
|
+
],
|
|
518
|
+
meta: { total: 2, page: 1 }
|
|
519
|
+
}.to_json
|
|
520
|
+
|
|
521
|
+
expect(json).to match_json_schema(schema)
|
|
149
522
|
```
|
|
150
523
|
|
|
151
|
-
|
|
524
|
+
#### Schema as JSON String
|
|
525
|
+
|
|
526
|
+
```ruby
|
|
527
|
+
schema_json = '{"type": "object", "required": ["id"]}'
|
|
528
|
+
expect('{"id": 1}').to match_json_schema(schema_json)
|
|
529
|
+
```
|
|
152
530
|
|
|
153
|
-
|
|
531
|
+
#### Array Validation
|
|
154
532
|
|
|
155
533
|
```ruby
|
|
156
|
-
|
|
534
|
+
# Array of specific type
|
|
535
|
+
schema = {
|
|
536
|
+
type: "object",
|
|
537
|
+
properties: {
|
|
538
|
+
tags: {
|
|
539
|
+
type: "array",
|
|
540
|
+
items: { type: "string" },
|
|
541
|
+
minItems: 1,
|
|
542
|
+
uniqueItems: true
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
expect('{"tags": ["ruby", "rails"]}').to match_json_schema(schema)
|
|
548
|
+
expect('{"tags": []}').not_to match_json_schema(schema) # minItems: 1
|
|
549
|
+
expect('{"tags": ["ruby", "ruby"]}').not_to match_json_schema(schema) # uniqueItems
|
|
550
|
+
|
|
551
|
+
# Array with mixed object types
|
|
552
|
+
schema = {
|
|
553
|
+
type: "array",
|
|
554
|
+
items: {
|
|
555
|
+
type: "object",
|
|
556
|
+
required: ["type"],
|
|
557
|
+
properties: {
|
|
558
|
+
type: { type: "string", enum: ["text", "image", "video"] },
|
|
559
|
+
url: { type: "string", format: "uri" }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
json = '[{"type": "text"}, {"type": "image", "url": "https://example.com/img.png"}]'
|
|
565
|
+
expect(json).to match_json_schema(schema)
|
|
157
566
|
```
|
|
158
567
|
|
|
159
|
-
|
|
568
|
+
#### String Formats
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
schema = {
|
|
572
|
+
type: "object",
|
|
573
|
+
properties: {
|
|
574
|
+
email: { type: "string", format: "email" },
|
|
575
|
+
website: { type: "string", format: "uri" },
|
|
576
|
+
uuid: { type: "string", format: "uuid" },
|
|
577
|
+
date: { type: "string", format: "date" },
|
|
578
|
+
datetime: { type: "string", format: "date-time" },
|
|
579
|
+
ipv4: { type: "string", format: "ipv4" }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
json = '{
|
|
584
|
+
"email": "user@example.com",
|
|
585
|
+
"website": "https://example.com",
|
|
586
|
+
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
587
|
+
"date": "2024-01-15",
|
|
588
|
+
"datetime": "2024-01-15T10:30:00Z",
|
|
589
|
+
"ipv4": "192.168.1.1"
|
|
590
|
+
}'
|
|
591
|
+
expect(json).to match_json_schema(schema)
|
|
592
|
+
```
|
|
160
593
|
|
|
161
|
-
|
|
594
|
+
#### Numeric Constraints
|
|
162
595
|
|
|
163
596
|
```ruby
|
|
164
|
-
|
|
597
|
+
schema = {
|
|
598
|
+
type: "object",
|
|
599
|
+
properties: {
|
|
600
|
+
age: { type: "integer", minimum: 0, maximum: 150 },
|
|
601
|
+
price: { type: "number", minimum: 0, exclusiveMinimum: true },
|
|
602
|
+
quantity: { type: "integer", multipleOf: 5 },
|
|
603
|
+
rating: { type: "number", minimum: 1, maximum: 5 }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
expect('{"age": 25, "price": 9.99, "quantity": 10, "rating": 4.5}').to match_json_schema(schema)
|
|
608
|
+
expect('{"age": -1}').not_to match_json_schema(schema) # minimum: 0
|
|
609
|
+
expect('{"price": 0}').not_to match_json_schema(schema) # exclusiveMinimum
|
|
165
610
|
```
|
|
166
611
|
|
|
167
|
-
|
|
612
|
+
#### Conditional Validation
|
|
168
613
|
|
|
169
|
-
|
|
614
|
+
```ruby
|
|
615
|
+
schema = {
|
|
616
|
+
type: "object",
|
|
617
|
+
properties: {
|
|
618
|
+
type: { type: "string", enum: ["personal", "business"] },
|
|
619
|
+
company_name: { type: "string" }
|
|
620
|
+
},
|
|
621
|
+
if: {
|
|
622
|
+
properties: { type: { const: "business" } }
|
|
623
|
+
},
|
|
624
|
+
then: {
|
|
625
|
+
required: ["company_name"]
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
expect('{"type": "personal"}').to match_json_schema(schema)
|
|
630
|
+
expect('{"type": "business", "company_name": "Acme Inc"}').to match_json_schema(schema)
|
|
631
|
+
expect('{"type": "business"}').not_to match_json_schema(schema) # missing company_name
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
#### Pattern Matching
|
|
170
635
|
|
|
171
636
|
```ruby
|
|
172
|
-
|
|
173
|
-
|
|
637
|
+
schema = {
|
|
638
|
+
type: "object",
|
|
639
|
+
properties: {
|
|
640
|
+
phone: { type: "string", pattern: "^\\+?[1-9]\\d{1,14}$" },
|
|
641
|
+
slug: { type: "string", pattern: "^[a-z0-9-]+$" },
|
|
642
|
+
hex_color: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
expect('{"phone": "+14155551234", "slug": "my-post", "hex_color": "#FF5733"}').to match_json_schema(schema)
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
#### Error Messages
|
|
650
|
+
|
|
651
|
+
When validation fails, you get detailed error messages:
|
|
652
|
+
|
|
653
|
+
```
|
|
654
|
+
Expected JSON to match schema.
|
|
655
|
+
Errors:
|
|
656
|
+
- value at `/name` is not a string at name
|
|
657
|
+
- object at root is missing required properties: email
|
|
658
|
+
|
|
659
|
+
Response: {"id":1,"name":123}
|
|
174
660
|
```
|
|
175
661
|
|
|
176
|
-
###
|
|
662
|
+
### HTTP Status Matchers
|
|
663
|
+
|
|
664
|
+
### have_http_status
|
|
177
665
|
|
|
178
|
-
|
|
666
|
+
Check for specific HTTP status codes using either numeric codes or symbolic names:
|
|
179
667
|
|
|
180
668
|
```ruby
|
|
181
|
-
|
|
182
|
-
expect(response
|
|
669
|
+
# Using numeric codes
|
|
670
|
+
expect(response).to have_http_status(200)
|
|
671
|
+
expect(response).to have_http_status(201)
|
|
672
|
+
expect(response).to have_http_status(404)
|
|
673
|
+
|
|
674
|
+
# Using symbolic names (Rails-style)
|
|
675
|
+
expect(response).to have_http_status(:ok)
|
|
676
|
+
expect(response).to have_http_status(:created)
|
|
677
|
+
expect(response).to have_http_status(:not_found)
|
|
678
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
679
|
+
|
|
680
|
+
# Negation
|
|
681
|
+
expect(response).not_to have_http_status(:ok)
|
|
183
682
|
```
|
|
184
683
|
|
|
185
|
-
###
|
|
684
|
+
### be_successful
|
|
186
685
|
|
|
187
|
-
|
|
686
|
+
Check if the response status is in the 2xx range:
|
|
188
687
|
|
|
189
688
|
```ruby
|
|
190
|
-
expect(response
|
|
689
|
+
expect(response).to be_successful # 200-299
|
|
690
|
+
expect(response).to be_success # alias
|
|
691
|
+
|
|
692
|
+
# Negation
|
|
693
|
+
expect(response).not_to be_successful
|
|
191
694
|
```
|
|
192
695
|
|
|
193
|
-
###
|
|
696
|
+
### be_redirect
|
|
194
697
|
|
|
195
|
-
|
|
698
|
+
Check if the response status is in the 3xx range:
|
|
196
699
|
|
|
197
700
|
```ruby
|
|
198
|
-
expect(response
|
|
199
|
-
expect(response
|
|
701
|
+
expect(response).to be_redirect # 300-399
|
|
702
|
+
expect(response).to be_redirection # alias
|
|
200
703
|
```
|
|
201
704
|
|
|
202
|
-
###
|
|
705
|
+
### be_client_error / be_server_error
|
|
203
706
|
|
|
204
|
-
|
|
707
|
+
Check for client (4xx) or server (5xx) errors:
|
|
205
708
|
|
|
206
709
|
```ruby
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
end
|
|
710
|
+
expect(response).to be_client_error # 400-499
|
|
711
|
+
expect(response).to be_server_error # 500-599
|
|
210
712
|
```
|
|
211
713
|
|
|
212
|
-
|
|
714
|
+
### Specific Status Matchers
|
|
715
|
+
|
|
716
|
+
Convenience matchers for common status codes:
|
|
213
717
|
|
|
214
718
|
```ruby
|
|
215
|
-
expect(response).to
|
|
719
|
+
expect(response).to be_not_found # 404
|
|
720
|
+
expect(response).to be_unauthorized # 401
|
|
721
|
+
expect(response).to be_forbidden # 403
|
|
722
|
+
expect(response).to be_unprocessable # 422
|
|
723
|
+
expect(response).to be_unprocessable_entity # alias for 422
|
|
724
|
+
expect(response).to be_no_content # 204
|
|
216
725
|
```
|
|
217
726
|
|
|
218
|
-
|
|
727
|
+
### JSON Structure Matchers
|
|
219
728
|
|
|
220
|
-
|
|
221
|
-
* `create_resource`
|
|
222
|
-
* `be_a_bad_request`
|
|
223
|
-
* `be_internal_server_error`
|
|
224
|
-
* `be_unauthorized`
|
|
225
|
-
* `be_forbidden`
|
|
226
|
-
* `be_not_found`
|
|
729
|
+
### have_json_keys
|
|
227
730
|
|
|
228
|
-
|
|
731
|
+
Verify that JSON contains specific keys at the root or at a given path:
|
|
229
732
|
|
|
230
|
-
|
|
733
|
+
```ruby
|
|
734
|
+
json = '{"id": 1, "name": "John", "email": "john@example.com"}'
|
|
735
|
+
|
|
736
|
+
# Check for multiple keys
|
|
737
|
+
expect(json).to have_json_keys(:id, :name, :email)
|
|
738
|
+
expect(json).to have_json_keys(:id, :name) # subset is OK
|
|
739
|
+
|
|
740
|
+
# At a specific path
|
|
741
|
+
json = '{"user": {"id": 1, "name": "John"}}'
|
|
742
|
+
expect(json).to have_json_keys(:id, :name).at_path("user")
|
|
743
|
+
|
|
744
|
+
# Negation
|
|
745
|
+
expect(json).not_to have_json_keys(:password, :token)
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
### have_json_type
|
|
749
|
+
|
|
750
|
+
Verify the type of a JSON value at a given path:
|
|
231
751
|
|
|
232
752
|
```ruby
|
|
233
|
-
|
|
753
|
+
json = '{"id": 1, "name": "John", "active": true, "tags": ["ruby"]}'
|
|
754
|
+
|
|
755
|
+
expect(json).to have_json_type(Integer).at_path("id")
|
|
756
|
+
expect(json).to have_json_type(String).at_path("name")
|
|
757
|
+
expect(json).to have_json_type(:boolean).at_path("active") # true or false
|
|
758
|
+
expect(json).to have_json_type(Array).at_path("tags")
|
|
759
|
+
expect(json).to have_json_type(Hash).at_path("user")
|
|
760
|
+
expect(json).to have_json_type(NilClass).at_path("deleted_at")
|
|
761
|
+
|
|
762
|
+
# Numeric types
|
|
763
|
+
expect(json).to have_json_type(Numeric).at_path("price") # Integer or Float
|
|
764
|
+
|
|
765
|
+
# Nested paths
|
|
766
|
+
json = '{"user": {"profile": {"age": 30}}}'
|
|
767
|
+
expect(json).to have_json_type(Integer).at_path("user.profile.age")
|
|
234
768
|
```
|
|
235
769
|
|
|
236
|
-
###
|
|
770
|
+
### Collection Matchers
|
|
237
771
|
|
|
238
|
-
|
|
772
|
+
### have_json_size
|
|
773
|
+
|
|
774
|
+
Check the size of a JSON array or hash:
|
|
239
775
|
|
|
240
776
|
```ruby
|
|
777
|
+
json = '{"users": [{"id": 1}, {"id": 2}, {"id": 3}]}'
|
|
778
|
+
|
|
779
|
+
expect(json).to have_json_size(3).at_path("users")
|
|
780
|
+
|
|
781
|
+
# At root level
|
|
782
|
+
expect('[1, 2, 3, 4, 5]').to have_json_size(5)
|
|
783
|
+
|
|
784
|
+
# Hash size (number of keys)
|
|
785
|
+
expect('{"a": 1, "b": 2}').to have_json_size(2)
|
|
786
|
+
|
|
787
|
+
# Negation
|
|
788
|
+
expect(json).not_to have_json_size(10).at_path("users")
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
### be_sorted_by
|
|
792
|
+
|
|
793
|
+
Check if a JSON array is sorted by a specific field:
|
|
794
|
+
|
|
795
|
+
```ruby
|
|
796
|
+
json = '[{"id": 1}, {"id": 2}, {"id": 3}]'
|
|
797
|
+
|
|
798
|
+
# Default ascending order
|
|
799
|
+
expect(json).to be_sorted_by(:id)
|
|
800
|
+
expect(json).to be_sorted_by(:id).ascending
|
|
801
|
+
|
|
802
|
+
# Descending order
|
|
803
|
+
json = '[{"id": 3}, {"id": 2}, {"id": 1}]'
|
|
804
|
+
expect(json).to be_sorted_by(:id).descending
|
|
805
|
+
|
|
806
|
+
# At a specific path
|
|
807
|
+
json = '{"users": [{"name": "Alice"}, {"name": "Bob"}, {"name": "Charlie"}]}'
|
|
808
|
+
expect(json).to be_sorted_by(:name).at_path("users")
|
|
809
|
+
|
|
810
|
+
# With dates
|
|
811
|
+
json = '[{"created_at": "2023-01-01"}, {"created_at": "2023-06-15"}]'
|
|
812
|
+
expect(json).to be_sorted_by(:created_at)
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
### Header Matchers
|
|
816
|
+
|
|
817
|
+
### be_json / be_xml
|
|
818
|
+
|
|
819
|
+
Check the Content-Type header:
|
|
820
|
+
|
|
821
|
+
```ruby
|
|
822
|
+
# Direct header value
|
|
823
|
+
expect("application/json; charset=utf-8").to be_json
|
|
824
|
+
expect("application/xml; charset=utf-8").to be_xml
|
|
825
|
+
|
|
826
|
+
# From response object
|
|
827
|
+
expect(response.headers['Content-Type']).to be_json
|
|
828
|
+
expect(response.headers['Content-Type']).to be_xml
|
|
829
|
+
|
|
830
|
+
# Negation
|
|
831
|
+
expect(response.headers['Content-Type']).not_to be_xml # when it's JSON
|
|
832
|
+
expect(response.headers['Content-Type']).not_to be_json # when it's XML
|
|
833
|
+
|
|
834
|
+
# Aliases available
|
|
241
835
|
expect(response.headers['Content-Type']).to be_in_json
|
|
836
|
+
expect(response.headers['Content-Type']).to be_in_xml
|
|
837
|
+
expect(response.headers['Content-Type']).to be_a_json
|
|
838
|
+
|
|
839
|
+
# With configuration (see Configuration section), use response directly
|
|
840
|
+
expect(response).to be_json
|
|
841
|
+
expect(response).to be_xml
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
### have_header
|
|
845
|
+
|
|
846
|
+
Check for the presence of HTTP headers with optional value matching:
|
|
847
|
+
|
|
848
|
+
```ruby
|
|
849
|
+
# Check header exists
|
|
850
|
+
expect(response).to have_header('X-Request-Id')
|
|
851
|
+
expect(response).to have_header('Content-Type')
|
|
852
|
+
|
|
853
|
+
# Check header with specific value
|
|
854
|
+
expect(response).to have_header('X-Request-Id').with_value('abc-123')
|
|
855
|
+
expect(response).to have_header('Content-Type').with_value('application/json')
|
|
856
|
+
|
|
857
|
+
# Check header matching a pattern
|
|
858
|
+
expect(response).to have_header('X-Request-Id').matching(/^[a-f0-9-]+$/)
|
|
859
|
+
expect(response).to have_header('Location').matching(/\/users\/\d+/)
|
|
860
|
+
|
|
861
|
+
# Negation
|
|
862
|
+
expect(response).not_to have_header('X-Internal-Only')
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
### have_cors_headers
|
|
866
|
+
|
|
867
|
+
Check for CORS (Cross-Origin Resource Sharing) headers:
|
|
868
|
+
|
|
869
|
+
```ruby
|
|
870
|
+
# Basic check for Access-Control-Allow-Origin
|
|
871
|
+
expect(response).to have_cors_headers
|
|
872
|
+
|
|
873
|
+
# Check for specific origin
|
|
874
|
+
expect(response).to have_cors_headers.for_origin('https://example.com')
|
|
875
|
+
|
|
876
|
+
# Negation
|
|
877
|
+
expect(response).not_to have_cors_headers
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
### have_cache_control
|
|
881
|
+
|
|
882
|
+
Check Cache-Control header directives:
|
|
883
|
+
|
|
884
|
+
```ruby
|
|
885
|
+
# Single directive
|
|
886
|
+
expect(response).to have_cache_control(:no_cache)
|
|
887
|
+
expect(response).to have_cache_control(:private)
|
|
888
|
+
|
|
889
|
+
# Multiple directives
|
|
890
|
+
expect(response).to have_cache_control(:private, :no_store)
|
|
891
|
+
expect(response).to have_cache_control(:public, 'max-age')
|
|
892
|
+
|
|
893
|
+
# Underscores are converted to dashes
|
|
894
|
+
expect(response).to have_cache_control(:must_revalidate) # checks must-revalidate
|
|
895
|
+
|
|
896
|
+
# Negation
|
|
897
|
+
expect(response).not_to have_cache_control(:public)
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
### Pagination Matchers
|
|
901
|
+
|
|
902
|
+
### be_paginated
|
|
903
|
+
|
|
904
|
+
Check if a response contains pagination metadata:
|
|
905
|
+
|
|
906
|
+
```ruby
|
|
907
|
+
json = '{"data": [], "meta": {"page": 1, "per_page": 10, "total": 100}}'
|
|
908
|
+
expect(json).to be_paginated
|
|
909
|
+
|
|
910
|
+
# Also works with links-style pagination
|
|
911
|
+
json = '{"data": [], "links": {"next": "/page/2", "prev": "/page/1"}}'
|
|
912
|
+
expect(json).to be_paginated
|
|
913
|
+
|
|
914
|
+
# Or root-level pagination keys
|
|
915
|
+
json = '{"items": [], "page": 1, "total_count": 50}'
|
|
916
|
+
expect(json).to be_paginated
|
|
917
|
+
|
|
918
|
+
# Negation
|
|
919
|
+
expect('{"data": []}').not_to be_paginated
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
### have_pagination_links
|
|
923
|
+
|
|
924
|
+
Check for specific pagination links:
|
|
925
|
+
|
|
926
|
+
```ruby
|
|
927
|
+
json = '{"data": [], "links": {"next": "/page/2", "prev": "/page/1", "first": "/page/1", "last": "/page/10"}}'
|
|
928
|
+
|
|
929
|
+
expect(json).to have_pagination_links(:next, :prev)
|
|
930
|
+
expect(json).to have_pagination_links(:first, :last)
|
|
931
|
+
expect(json).to have_pagination_links(:next) # single link
|
|
932
|
+
|
|
933
|
+
# Negation
|
|
934
|
+
expect(json).not_to have_pagination_links(:next)
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
### have_total_count
|
|
938
|
+
|
|
939
|
+
Check for total count in pagination metadata:
|
|
940
|
+
|
|
941
|
+
```ruby
|
|
942
|
+
json = '{"data": [], "meta": {"total": 100}}'
|
|
943
|
+
expect(json).to have_total_count(100)
|
|
944
|
+
|
|
945
|
+
# Works with various key names
|
|
946
|
+
json = '{"items": [], "total_count": 50}'
|
|
947
|
+
expect(json).to have_total_count(50)
|
|
948
|
+
|
|
949
|
+
# Negation
|
|
950
|
+
expect(json).not_to have_total_count(200)
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
### Error Response Matchers
|
|
954
|
+
|
|
955
|
+
### have_error / have_errors
|
|
956
|
+
|
|
957
|
+
Check if a response contains error information:
|
|
958
|
+
|
|
959
|
+
```ruby
|
|
960
|
+
# Array of errors
|
|
961
|
+
json = '{"errors": [{"message": "Name is required"}]}'
|
|
962
|
+
expect(json).to have_error
|
|
963
|
+
expect(json).to have_errors # alias
|
|
964
|
+
|
|
965
|
+
# Single error object
|
|
966
|
+
json = '{"error": "Something went wrong"}'
|
|
967
|
+
expect(json).to have_error
|
|
968
|
+
|
|
969
|
+
# Error message key
|
|
970
|
+
json = '{"message": "Resource not found"}'
|
|
971
|
+
expect(json).to have_error
|
|
972
|
+
|
|
973
|
+
# Negation
|
|
974
|
+
expect('{"data": {"id": 1}}').not_to have_error
|
|
242
975
|
```
|
|
243
976
|
|
|
244
|
-
###
|
|
977
|
+
### have_error_on
|
|
245
978
|
|
|
246
|
-
|
|
979
|
+
Check for errors on specific fields:
|
|
247
980
|
|
|
248
981
|
```ruby
|
|
982
|
+
# API-style errors (array of error objects)
|
|
983
|
+
json = '{"errors": [{"field": "email", "message": "is invalid"}]}'
|
|
984
|
+
expect(json).to have_error_on(:email)
|
|
985
|
+
expect(json).to have_error_on(:email).with_message("is invalid")
|
|
986
|
+
|
|
987
|
+
# Rails-style errors (hash with field keys)
|
|
988
|
+
json = '{"email": ["is invalid", "is already taken"]}'
|
|
989
|
+
expect(json).to have_error_on(:email)
|
|
990
|
+
expect(json).to have_error_on(:email).with_message("is invalid")
|
|
991
|
+
|
|
992
|
+
# Pattern matching
|
|
993
|
+
expect(json).to have_error_on(:email).matching(/invalid/i)
|
|
994
|
+
|
|
995
|
+
# Negation
|
|
996
|
+
expect(json).not_to have_error_on(:name)
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
### JSON:API Matchers
|
|
1000
|
+
|
|
1001
|
+
### be_json_api_compliant
|
|
1002
|
+
|
|
1003
|
+
Validate that a response follows the [JSON:API specification](https://jsonapi.org/):
|
|
1004
|
+
|
|
1005
|
+
```ruby
|
|
1006
|
+
json = '{"data": {"id": "1", "type": "users", "attributes": {"name": "John"}}}'
|
|
1007
|
+
expect(json).to be_json_api_compliant
|
|
1008
|
+
|
|
1009
|
+
# With errors
|
|
1010
|
+
json = '{"errors": [{"status": "404", "title": "Not Found"}]}'
|
|
1011
|
+
expect(json).to be_json_api_compliant
|
|
1012
|
+
|
|
1013
|
+
# With meta only
|
|
1014
|
+
json = '{"meta": {"total": 100}}'
|
|
1015
|
+
expect(json).to be_json_api_compliant
|
|
1016
|
+
|
|
1017
|
+
# Validates structure requirements:
|
|
1018
|
+
# - Must have data, errors, or meta
|
|
1019
|
+
# - data and errors cannot coexist
|
|
1020
|
+
# - Resources must have type
|
|
1021
|
+
# - etc.
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
### have_json_api_data
|
|
1025
|
+
|
|
1026
|
+
Check for JSON:API data member with optional type and id matching:
|
|
1027
|
+
|
|
1028
|
+
```ruby
|
|
1029
|
+
json = '{"data": {"id": "1", "type": "users", "attributes": {"name": "John"}}}'
|
|
1030
|
+
|
|
1031
|
+
expect(json).to have_json_api_data
|
|
1032
|
+
expect(json).to have_json_api_data.of_type("users")
|
|
1033
|
+
expect(json).to have_json_api_data.with_id("1")
|
|
1034
|
+
expect(json).to have_json_api_data.of_type("users").with_id("1")
|
|
1035
|
+
|
|
1036
|
+
# Works with arrays
|
|
1037
|
+
json = '{"data": [{"id": "1", "type": "users"}, {"id": "2", "type": "users"}]}'
|
|
1038
|
+
expect(json).to have_json_api_data.of_type("users")
|
|
1039
|
+
expect(json).to have_json_api_data.with_id("2")
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
### have_json_api_attributes
|
|
1043
|
+
|
|
1044
|
+
Check for attributes in JSON:API data:
|
|
1045
|
+
|
|
1046
|
+
```ruby
|
|
1047
|
+
json = '{"data": {"id": "1", "type": "users", "attributes": {"name": "John", "email": "john@example.com"}}}'
|
|
1048
|
+
|
|
1049
|
+
expect(json).to have_json_api_attributes(:name, :email)
|
|
1050
|
+
expect(json).to have_json_api_attributes(:name)
|
|
1051
|
+
|
|
1052
|
+
# Negation
|
|
1053
|
+
expect(json).not_to have_json_api_attributes(:password)
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
### have_json_api_relationships
|
|
1057
|
+
|
|
1058
|
+
Check for relationships in JSON:API data:
|
|
1059
|
+
|
|
1060
|
+
```ruby
|
|
1061
|
+
json = '{
|
|
1062
|
+
"data": {
|
|
1063
|
+
"id": "1",
|
|
1064
|
+
"type": "posts",
|
|
1065
|
+
"relationships": {
|
|
1066
|
+
"author": {"data": {"id": "1", "type": "users"}},
|
|
1067
|
+
"comments": {"data": []}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}'
|
|
1071
|
+
|
|
1072
|
+
expect(json).to have_json_api_relationships(:author, :comments)
|
|
1073
|
+
expect(json).to have_json_api_relationships(:author)
|
|
1074
|
+
|
|
1075
|
+
# Negation
|
|
1076
|
+
expect(json).not_to have_json_api_relationships(:tags)
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
### HATEOAS Matchers
|
|
1080
|
+
|
|
1081
|
+
### have_link
|
|
1082
|
+
|
|
1083
|
+
Check for HATEOAS (Hypermedia as the Engine of Application State) links:
|
|
1084
|
+
|
|
1085
|
+
```ruby
|
|
1086
|
+
# HAL-style links
|
|
1087
|
+
json = '{"_links": {"self": {"href": "/users/1"}, "posts": {"href": "/users/1/posts"}}}'
|
|
1088
|
+
|
|
1089
|
+
expect(json).to have_link(:self)
|
|
1090
|
+
expect(json).to have_link(:posts)
|
|
1091
|
+
|
|
1092
|
+
# With exact href match
|
|
1093
|
+
expect(json).to have_link(:self).with_href("/users/1")
|
|
1094
|
+
|
|
1095
|
+
# With pattern match
|
|
1096
|
+
expect(json).to have_link(:self).with_href(/\/users\/\d+/)
|
|
1097
|
+
|
|
1098
|
+
# Simple links format
|
|
1099
|
+
json = '{"links": {"self": "/users/1"}}'
|
|
1100
|
+
expect(json).to have_link(:self).with_href("/users/1")
|
|
1101
|
+
|
|
1102
|
+
# Negation
|
|
1103
|
+
expect(json).not_to have_link(:delete)
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
## Configuration
|
|
1107
|
+
|
|
1108
|
+
Configure APIMatchers to work seamlessly with your test setup:
|
|
1109
|
+
|
|
1110
|
+
```ruby
|
|
1111
|
+
# spec/spec_helper.rb or spec/support/api_matchers.rb
|
|
249
1112
|
APIMatchers.setup do |config|
|
|
250
|
-
|
|
1113
|
+
# Automatically extract body from response objects
|
|
1114
|
+
config.response_body_method = :body
|
|
1115
|
+
|
|
1116
|
+
# Configure header access for be_json/be_xml and header matchers
|
|
1117
|
+
config.header_method = :headers
|
|
251
1118
|
config.header_content_type_key = 'Content-Type'
|
|
1119
|
+
|
|
1120
|
+
# Set default format for have_node matcher (:json or :xml)
|
|
1121
|
+
config.have_node_matcher = :json
|
|
1122
|
+
|
|
1123
|
+
# HTTP status extraction method (for status matchers)
|
|
1124
|
+
config.http_status_method = :status
|
|
1125
|
+
|
|
1126
|
+
# Pagination configuration
|
|
1127
|
+
config.pagination_meta_path = 'meta' # path to pagination metadata
|
|
1128
|
+
config.pagination_links_path = 'links' # path to pagination links
|
|
1129
|
+
|
|
1130
|
+
# Error response configuration
|
|
1131
|
+
config.errors_path = 'errors' # path to errors array
|
|
1132
|
+
config.error_message_key = 'message' # key for error message
|
|
1133
|
+
config.error_field_key = 'field' # key for error field name
|
|
1134
|
+
|
|
1135
|
+
# HATEOAS links configuration
|
|
1136
|
+
config.links_path = '_links' # path to HATEOAS links (HAL style)
|
|
1137
|
+
end
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
### With Rails
|
|
1141
|
+
|
|
1142
|
+
```ruby
|
|
1143
|
+
APIMatchers.setup do |config|
|
|
1144
|
+
config.response_body_method = :body
|
|
1145
|
+
config.header_method = :headers
|
|
1146
|
+
config.header_content_type_key = 'Content-Type'
|
|
1147
|
+
config.http_status_method = :status
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
# Now you can use response directly:
|
|
1151
|
+
RSpec.describe "API", type: :request do
|
|
1152
|
+
it "returns JSON" do
|
|
1153
|
+
get "/api/users/1"
|
|
1154
|
+
|
|
1155
|
+
expect(response).to have_http_status(:ok)
|
|
1156
|
+
expect(response).to have_json_node(:id).with(1)
|
|
1157
|
+
expect(response).to be_json
|
|
1158
|
+
end
|
|
252
1159
|
end
|
|
253
1160
|
```
|
|
254
1161
|
|
|
255
|
-
|
|
1162
|
+
### With HTTP Clients (HTTParty, Faraday, etc.)
|
|
256
1163
|
|
|
257
1164
|
```ruby
|
|
258
|
-
|
|
259
|
-
|
|
1165
|
+
APIMatchers.setup do |config|
|
|
1166
|
+
config.response_body_method = :body
|
|
1167
|
+
config.header_method = :headers
|
|
1168
|
+
config.header_content_type_key = 'content-type' # Note: lowercase for some clients
|
|
1169
|
+
config.http_status_method = :code # or :status depending on client
|
|
1170
|
+
end
|
|
260
1171
|
```
|
|
261
1172
|
|
|
262
|
-
|
|
1173
|
+
## Upgrading from 0.x to 1.0
|
|
1174
|
+
|
|
1175
|
+
### Breaking Changes
|
|
1176
|
+
|
|
1177
|
+
1. **Ruby 3.1+ required** - Ruby 1.9, 2.x, and early 3.x versions are no longer supported.
|
|
1178
|
+
|
|
1179
|
+
2. **HTTP Status Matchers Renamed** - The old status matchers have been replaced with new, more comprehensive ones:
|
|
1180
|
+
|
|
1181
|
+
| Old Matcher (0.x) | New Matcher (1.0) |
|
|
1182
|
+
|-------------------|-------------------|
|
|
1183
|
+
| `be_ok` | `have_http_status(:ok)` or `be_successful` |
|
|
1184
|
+
| `create_resource` | `have_http_status(:created)` |
|
|
1185
|
+
| `be_bad_request` | `have_http_status(:bad_request)` or `be_client_error` |
|
|
1186
|
+
| `be_unauthorized` | `be_unauthorized` (unchanged) |
|
|
1187
|
+
| `be_forbidden` | `be_forbidden` (unchanged) |
|
|
1188
|
+
| `be_not_found` | `be_not_found` (unchanged) |
|
|
1189
|
+
| `be_unprocessable_entity` | `be_unprocessable` or `be_unprocessable_entity` |
|
|
1190
|
+
| `be_internal_server_error` | `have_http_status(:internal_server_error)` or `be_server_error` |
|
|
263
1191
|
|
|
264
|
-
|
|
1192
|
+
### New Features in 1.0
|
|
265
1193
|
|
|
266
|
-
|
|
1194
|
+
- **HTTP Status Matchers** - `have_http_status`, `be_successful`, `be_redirect`, `be_client_error`, `be_server_error`, and specific status matchers
|
|
1195
|
+
- **JSON Structure Matchers** - `have_json_keys`, `have_json_type`
|
|
1196
|
+
- **Collection Matchers** - `have_json_size`, `be_sorted_by`
|
|
1197
|
+
- **Header Matchers** - `have_header`, `have_cors_headers`, `have_cache_control`
|
|
1198
|
+
- **Pagination Matchers** - `be_paginated`, `have_pagination_links`, `have_total_count`
|
|
1199
|
+
- **Error Response Matchers** - `have_error`, `have_errors`, `have_error_on`
|
|
1200
|
+
- **JSON:API Matchers** - `be_json_api_compliant`, `have_json_api_data`, `have_json_api_attributes`, `have_json_api_relationships`
|
|
1201
|
+
- **HATEOAS Matchers** - `have_link`
|
|
1202
|
+
- **`including(attributes)`** - Check if a JSON array contains an element matching the given attributes
|
|
1203
|
+
- **`including_all(elements)`** - Check if a JSON array contains all specified elements
|
|
1204
|
+
- **`match_json_schema(schema)`** - Validate JSON against a JSON Schema (requires `json_schemer` gem)
|
|
1205
|
+
|
|
1206
|
+
## Contributing
|
|
1207
|
+
|
|
1208
|
+
1. Fork it
|
|
1209
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
1210
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
1211
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
1212
|
+
5. Create new Pull Request
|
|
1213
|
+
|
|
1214
|
+
## Acknowledgements
|
|
1215
|
+
|
|
1216
|
+
* Special thanks to Daniel Konishi for contributing to the product from which I extracted the matchers for this gem.
|
|
1217
|
+
|
|
1218
|
+
## Contributors
|
|
267
1219
|
|
|
268
1220
|
* Stephen Orens
|
|
269
1221
|
* Lucas Caton
|
|
1222
|
+
|
|
1223
|
+
## License
|
|
1224
|
+
|
|
1225
|
+
MIT License. See [LICENSE](LICENSE) for details.
|