@klondikemarlen/expect-http-response 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +193 -2
- package/index.d.mts +44 -0
- package/index.mjs +109 -0
- package/package.json +48 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marlen Brunner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,194 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @klondikemarlen/expect-http-response
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One `expect` can describe an HTTP response contract: its status and complete body. This dependency-free ESM matcher works with Jest's `expect` and Vitest. Register it explicitly with your existing test runner.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install a published release as a development dependency:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --save-dev @klondikemarlen/expect-http-response
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Alternatively, install directly from this focused repository:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install --save-dev github:klondikemarlen/expect-http-response
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use an ESM project (`"type": "module"`) or `.mjs` setup/config files. The package has no runtime dependencies and exports `toHaveHttpResponse` from its root only. Consumers supply their own test runner and explicitly register the matcher; registration is not automatic.
|
|
20
|
+
|
|
21
|
+
## Registering the Matcher
|
|
22
|
+
|
|
23
|
+
Register once in the setup module for the runner you use. The examples use the runner's own `expect` instance.
|
|
24
|
+
|
|
25
|
+
### Jest
|
|
26
|
+
|
|
27
|
+
Jest setup files run after Jest initializes its assertion environment:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
// test/setup-http-response-matcher.mjs
|
|
31
|
+
import { expect } from "@jest/globals"
|
|
32
|
+
import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
|
|
33
|
+
|
|
34
|
+
expect.extend({ toHaveHttpResponse })
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// jest.config.mjs
|
|
39
|
+
export default {
|
|
40
|
+
setupFilesAfterEnv: ["<rootDir>/test/setup-http-response-matcher.mjs"],
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For Jest's native ESM mode, invoke the existing Jest command with Node's VM-module support, for example:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
node --experimental-vm-modules node_modules/jest/bin/jest.js
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Preserve the project's existing transforms and test command when integrating the setup.
|
|
51
|
+
|
|
52
|
+
### Vitest
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
// test/setup-http-response-matcher.mjs
|
|
56
|
+
import { expect } from "vitest"
|
|
57
|
+
import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
|
|
58
|
+
|
|
59
|
+
expect.extend({ toHaveHttpResponse })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
// vitest.config.mjs
|
|
64
|
+
import { defineConfig } from "vitest/config"
|
|
65
|
+
|
|
66
|
+
export default defineConfig({
|
|
67
|
+
test: {
|
|
68
|
+
setupFiles: ["./test/setup-http-response-matcher.mjs"],
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Other Compatible Runners
|
|
74
|
+
|
|
75
|
+
Import the matcher from the package root and extend the active runner's `expect` instance explicitly. The matcher relies on the runner's equality function and custom matcher context; it does not bundle or select a runner.
|
|
76
|
+
|
|
77
|
+
## TypeScript
|
|
78
|
+
|
|
79
|
+
The package root resolves to `index.d.mts`. Add a declaration augmentation for each runner you actually install. For Jest's `expect` package:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// test/http-response-matchers.d.ts
|
|
83
|
+
import "expect"
|
|
84
|
+
|
|
85
|
+
declare module "expect" {
|
|
86
|
+
interface Matchers<R extends void | Promise<void>, T = unknown> {
|
|
87
|
+
toHaveHttpResponse(expectedStatus: number, expectedBody: unknown): R
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For Vitest:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// test/http-response-matchers.d.ts
|
|
96
|
+
import "vitest"
|
|
97
|
+
|
|
98
|
+
declare module "vitest" {
|
|
99
|
+
interface Matchers<R, T> {
|
|
100
|
+
toHaveHttpResponse(expectedStatus: number, expectedBody: unknown): R
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Ensure TypeScript includes the augmentation as well as the tests (merge these entries into the existing `include` paths rather than replacing source entries):
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"include": ["src/**/*.ts", "test/**/*.ts", "test/http-response-matchers.d.ts"]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The TypeScript setup module must also be included if it is written in TypeScript. Explicitly importing `expect` from `vitest` or `@jest/globals` does not require runner globals in `compilerOptions.types`. Do not add an augmentation for a runner that is not installed.
|
|
114
|
+
|
|
115
|
+
For a Node.js project, keep `@types/node` installed and include `"node"` in `compilerOptions.types` if the project's configuration does not already load it. Node types are separate from Jest/Vitest globals.
|
|
116
|
+
|
|
117
|
+
## Example: Express and Supertest
|
|
118
|
+
|
|
119
|
+
This makes a real request to an Express route. The single assertion is the response contract; it makes no separate claim about persistence or other effects.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import express from "express"
|
|
123
|
+
import { expect, test } from "vitest"
|
|
124
|
+
import request from "supertest"
|
|
125
|
+
import { toHaveHttpResponse } from "@klondikemarlen/expect-http-response"
|
|
126
|
+
|
|
127
|
+
expect.extend({ toHaveHttpResponse })
|
|
128
|
+
|
|
129
|
+
const app = express()
|
|
130
|
+
app.get("/users/:id", (req, res) => {
|
|
131
|
+
res.status(200).json({
|
|
132
|
+
user: { id: Number(req.params.id), name: "Ada" },
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("when the user exists, returns the complete user response", async () => {
|
|
137
|
+
const response = await request(app).get("/users/7")
|
|
138
|
+
|
|
139
|
+
expect(response).toHaveHttpResponse(200, {
|
|
140
|
+
user: { id: 7, name: "Ada" },
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Body Equality, Negation, and Invalid Arguments
|
|
146
|
+
|
|
147
|
+
`toHaveHttpResponse(status, body)` passes only when the numeric response `status` and complete `body` both match. Body comparison uses the active runner's equality function and custom equality testers, plus iterable equality when the runner exposes it, consistent with `toEqual`. Transport fields such as request, socket, or response internals are not inspected.
|
|
148
|
+
|
|
149
|
+
- Body equality is complete: unexpected body keys fail, just as they do with `toEqual`.
|
|
150
|
+
- Asymmetric values such as `expect.objectContaining(...)` deliberately request a partial body match.
|
|
151
|
+
- `.not` negates the combined status-and-body contract. It passes if either status or body differs and fails only when both match.
|
|
152
|
+
- The expected status and received `response.status` must be finite numbers. An Express `res.status()` method is not a sent response status.
|
|
153
|
+
- Invalid inputs throw even through `.not`; they are not treated as a successful negative assertion.
|
|
154
|
+
- Supply the body argument explicitly. Pass `undefined` when the expected response body is undefined.
|
|
155
|
+
|
|
156
|
+
## Package Development
|
|
157
|
+
|
|
158
|
+
The package's focused test suite includes matcher unit tests using Jest's `expect`, a real Express/Supertest integration test using Vitest, and strict TypeScript registration checks for both runners.
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
npm ci
|
|
162
|
+
npm test
|
|
163
|
+
npm run test:unit
|
|
164
|
+
npm run test:integration
|
|
165
|
+
npm run typecheck
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Release
|
|
169
|
+
|
|
170
|
+
From the repository's merged `main` branch, verify the package before tagging and publishing. Release publication is not complete until the registry artifact is installed and exercised:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
npm test
|
|
174
|
+
npm pack --dry-run
|
|
175
|
+
git switch main
|
|
176
|
+
git pull --ff-only origin main
|
|
177
|
+
git tag v1.0.0
|
|
178
|
+
git push origin v1.0.0
|
|
179
|
+
gh release create v1.0.0 --title v1.0.0 --generate-notes
|
|
180
|
+
npm publish --access public
|
|
181
|
+
npm view @klondikemarlen/expect-http-response@1.0.0 version
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
In a separate consumer project, install the registry artifact:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
npm install --save-dev @klondikemarlen/expect-http-response@1.0.0
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
After publishing, use the actual registry installation in a fresh consumer project and run its matcher and TypeScript registration checks. Do not claim npm availability until the publish and registry install have been observed.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT. See [LICENSE](LICENSE).
|
package/index.d.mts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type EqualityTester = (this: any, ...values: any[]) => boolean | undefined
|
|
2
|
+
|
|
3
|
+
type EqualityFunction = (
|
|
4
|
+
received: unknown,
|
|
5
|
+
expected: unknown,
|
|
6
|
+
customTesters?: EqualityTester[],
|
|
7
|
+
strictCheck?: boolean
|
|
8
|
+
) => boolean
|
|
9
|
+
|
|
10
|
+
interface HttpResponseLike {
|
|
11
|
+
readonly status: number
|
|
12
|
+
readonly body?: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface MatcherUtils {
|
|
16
|
+
matcherHint?: (
|
|
17
|
+
matcherName: string,
|
|
18
|
+
received?: string,
|
|
19
|
+
expected?: string,
|
|
20
|
+
options?: { isNot?: boolean }
|
|
21
|
+
) => string
|
|
22
|
+
printExpected?: (value: unknown) => string
|
|
23
|
+
printReceived?: (value: unknown) => string
|
|
24
|
+
iterableEquality?: EqualityTester
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MatcherContext {
|
|
28
|
+
readonly isNot?: boolean
|
|
29
|
+
readonly customTesters?: EqualityTester[]
|
|
30
|
+
readonly equals: EqualityFunction
|
|
31
|
+
readonly utils?: MatcherUtils
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface MatcherResult {
|
|
35
|
+
readonly pass: boolean
|
|
36
|
+
readonly message: () => string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export declare function toHaveHttpResponse(
|
|
40
|
+
this: MatcherContext,
|
|
41
|
+
received: HttpResponseLike,
|
|
42
|
+
expectedStatus: number,
|
|
43
|
+
expectedBody: unknown
|
|
44
|
+
): MatcherResult
|
package/index.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const MATCHER_NAME = "toHaveHttpResponse"
|
|
2
|
+
|
|
3
|
+
export function toHaveHttpResponse(response, expectedStatus, expectedBody) {
|
|
4
|
+
if (arguments.length < 3) {
|
|
5
|
+
throw new TypeError(
|
|
6
|
+
`${MATCHER_NAME} requires an expected status and body. Pass undefined explicitly for an undefined body.`
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof expectedStatus !== "number" || !Number.isFinite(expectedStatus)) {
|
|
11
|
+
throw new TypeError("expectedStatus must be a finite number.")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (typeof response !== "object" || response === null) {
|
|
15
|
+
throw new TypeError("received value must be a non-null HTTP response object.")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const receivedStatus = response.status
|
|
19
|
+
if (typeof receivedStatus !== "number" || !Number.isFinite(receivedStatus)) {
|
|
20
|
+
throw new TypeError(
|
|
21
|
+
"received response.status must be a finite number; an Express status() method is not a response status value."
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof this?.equals !== "function") {
|
|
26
|
+
throw new TypeError("the test runner must provide an equality function for custom matchers.")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const equalityTesters = [...(this.customTesters ?? [])]
|
|
30
|
+
const iterableEquality = this.utils?.iterableEquality
|
|
31
|
+
if (typeof iterableEquality === "function" && !equalityTesters.includes(iterableEquality)) {
|
|
32
|
+
equalityTesters.push(iterableEquality)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const receivedBody = response.body
|
|
36
|
+
const statusMatches = receivedStatus === expectedStatus
|
|
37
|
+
const bodyMatches = this.equals(receivedBody, expectedBody, equalityTesters)
|
|
38
|
+
const pass = statusMatches && bodyMatches
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
pass,
|
|
42
|
+
message: () =>
|
|
43
|
+
formatMessage(this, {
|
|
44
|
+
expectedBody,
|
|
45
|
+
expectedStatus,
|
|
46
|
+
receivedBody,
|
|
47
|
+
receivedStatus,
|
|
48
|
+
statusMatches,
|
|
49
|
+
bodyMatches,
|
|
50
|
+
}),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatMessage(context, result) {
|
|
55
|
+
const hint = matcherHint(context)
|
|
56
|
+
if (context.isNot) {
|
|
57
|
+
return [
|
|
58
|
+
hint,
|
|
59
|
+
"",
|
|
60
|
+
"Expected the response not to match this HTTP contract:",
|
|
61
|
+
`Expected status: ${printValue(context, "printExpected", result.expectedStatus)}`,
|
|
62
|
+
`Received status: ${printValue(context, "printReceived", result.receivedStatus)}`,
|
|
63
|
+
`Expected body: ${printValue(context, "printExpected", result.expectedBody)}`,
|
|
64
|
+
`Received body: ${printValue(context, "printReceived", result.receivedBody)}`,
|
|
65
|
+
].join("\n")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lines = [hint]
|
|
69
|
+
if (!result.statusMatches) {
|
|
70
|
+
lines.push(
|
|
71
|
+
"",
|
|
72
|
+
`Status mismatch: expected ${printValue(context, "printExpected", result.expectedStatus)}, received ${printValue(context, "printReceived", result.receivedStatus)}.`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!result.bodyMatches) {
|
|
77
|
+
lines.push(
|
|
78
|
+
"",
|
|
79
|
+
"Body mismatch:",
|
|
80
|
+
`Expected body: ${printValue(context, "printExpected", result.expectedBody)}`,
|
|
81
|
+
`Received body: ${printValue(context, "printReceived", result.receivedBody)}`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return lines.join("\n")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matcherHint(context) {
|
|
89
|
+
if (typeof context.utils?.matcherHint === "function") {
|
|
90
|
+
return context.utils.matcherHint(
|
|
91
|
+
`.${MATCHER_NAME}`,
|
|
92
|
+
undefined,
|
|
93
|
+
"expectedStatus, expectedBody",
|
|
94
|
+
{ isNot: context.isNot }
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const negation = context.isNot ? ".not" : ""
|
|
99
|
+
return `expect(received)${negation}.${MATCHER_NAME}(expectedStatus, expectedBody)`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function printValue(context, formatterName, value) {
|
|
103
|
+
const formatter = context.utils?.[formatterName]
|
|
104
|
+
if (typeof formatter === "function") {
|
|
105
|
+
return formatter(value)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return String(value)
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klondikemarlen/expect-http-response",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "One-expect HTTP response contracts for Jest and Vitest",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.d.mts",
|
|
10
|
+
"import": "./index.mjs"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.mjs",
|
|
15
|
+
"index.d.mts",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"registry": "https://registry.npmjs.org/"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test tests/to-have-http-response.test.mjs && vitest run && tsc --noEmit -p tsconfig.json",
|
|
25
|
+
"test:unit": "node --test tests/to-have-http-response.test.mjs",
|
|
26
|
+
"test:integration": "vitest run",
|
|
27
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/klondikemarlen/expect-http-response.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/klondikemarlen/expect-http-response#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/klondikemarlen/expect-http-response/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20.19.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/express": "^5.0.6",
|
|
42
|
+
"@types/node": "^26.6.5",
|
|
43
|
+
"@types/supertest": "^7.2.1",
|
|
44
|
+
"expect": "^30.5.2",
|
|
45
|
+
"express": "^5.3.0",
|
|
46
|
+
"supertest": "^7.3.1",
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vitest": "^5.0.4"
|
|
49
|
+
}
|
|
50
|
+
}
|