Skip to content

Commit 344b054

Browse files
bpamiriclaude
andauthored
feat(test): add HTTP test client for integration testing (#2099)
* feat(test): add HTTP test client for integration testing Adds a fluent HTTP test client inspired by Laravel's test helpers. TestClient provides chainable request methods (get/post/put/patch/delete) and assertion methods (assertStatus/assertSee/assertJson/etc.) for writing concise integration tests. WheelsTest gains visit() and $testClient() helpers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): address review issues in HTTP test client - Fix cookie parsing bug: values containing '=' were truncated (ListToArray split on every '='; now uses Find/Mid for first '=' only) - Make $makeRequest and $assertionError private (standalone CFC, not mixin) - Remove redundant exact-match check in assertRedirect() - Change assertJsonPath to 1-based array indexing (CFML convention) - Rename 'client' variable to 'tc' in spec (CFML reserves 'client' scope) - Remove silent-skip patterns: tests now assert preconditions or fail - Add tests for put(), patch(), delete() methods - Add tests for POST with body (form fields + JSON) - Remove framework reload side effect from assertOk() test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): prevent recursive HTTP deadlock in test client spec Tests that called /wheels/core/tests from within the test runner caused a thread pool deadlock on CI (single-threaded Lucee). Replaced with $fakeClient() helper that sets internal response state directly, allowing assertion logic to be tested without live HTTP calls to the test endpoint. Live HTTP tests still use "/" which is safe. Also adds $setFakeResponse() to TestClient for test isolation, and improves assertSeeInOrder/assertJsonPath/assertRedirect coverage using deterministic fake data instead of parsing live HTML responses. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): rename assertStatus parameter to avoid Lucee 7 shadowing Lucee 7 cannot resolve statusCode() as a method call when the enclosing function has a parameter named statusCode — the parameter shadows the method name. Renamed parameter to expectedStatus. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(test): use fake client for status-dependent assertions Root path (/) returns 500 during test runs because the framework is mid-request. Use $fakeClient() for assertOk() and chaining tests that depend on specific status codes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6811c7e commit 344b054

3 files changed

Lines changed: 869 additions & 0 deletions

File tree

‎vendor/wheels/WheelsTest.cfc‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,36 @@ component extends="wheels.wheelstest.system.BaseSpec" {
2525
}
2626
}
2727

28+
/**
29+
* Create a TestClient and visit the given path (HTTP GET).
30+
* Returns the TestClient for fluent assertion chaining.
31+
*
32+
* Usage in tests:
33+
* visit("/users").assertOk().assertSee("John")
34+
*
35+
* @path URL path to visit
36+
*/
37+
public any function visit(required string path) {
38+
return $testClient().get(arguments.path);
39+
}
40+
41+
/**
42+
* Return a configured TestClient instance.
43+
* The base URL is auto-detected from the current server port.
44+
*/
45+
public any function $testClient() {
46+
return new wheels.wheelstest.TestClient(baseUrl = $getTestBaseUrl());
47+
}
48+
49+
/**
50+
* Auto-detect the base URL of the running test server.
51+
*/
52+
private string function $getTestBaseUrl() {
53+
var port = CGI.SERVER_PORT;
54+
if (!Len(port) || port == 0) {
55+
port = 8080;
56+
}
57+
return "http://localhost:" & port;
58+
}
59+
2860
}
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
/**
4+
* Helper: create a TestClient with a pre-set fake response for unit-testing
5+
* assertion logic without making real HTTP calls.
6+
*/
7+
private any function $fakeClient(string body = "", numeric status = 200, struct headers = {}) {
8+
var fc = new wheels.wheelstest.TestClient();
9+
// Directly set internal state to test assertions in isolation
10+
fc.$setFakeResponse(
11+
statusCode = "#arguments.status# OK",
12+
fileContent = arguments.body,
13+
responseHeader = arguments.headers
14+
);
15+
return fc;
16+
}
17+
18+
function run() {
19+
20+
describe("TestClient", () => {
21+
22+
beforeEach(() => {
23+
tc = $testClient();
24+
});
25+
26+
describe("initialization", () => {
27+
28+
it("initializes with default baseUrl", () => {
29+
var c = new wheels.wheelstest.TestClient();
30+
expect(c).toBeInstanceOf("wheels.wheelstest.TestClient");
31+
});
32+
33+
it("initializes with custom baseUrl", () => {
34+
var c = new wheels.wheelstest.TestClient(baseUrl = "http://localhost:9999");
35+
expect(c).toBeInstanceOf("wheels.wheelstest.TestClient");
36+
});
37+
38+
});
39+
40+
describe("request methods", () => {
41+
42+
it("get() makes HTTP GET request", () => {
43+
tc.get("/");
44+
expect(tc.statusCode()).toBeGT(0);
45+
});
46+
47+
it("post() makes HTTP POST request", () => {
48+
tc.post("/");
49+
expect(tc.statusCode()).toBeGT(0);
50+
});
51+
52+
it("put() makes HTTP PUT request", () => {
53+
tc.put("/");
54+
expect(tc.statusCode()).toBeGT(0);
55+
});
56+
57+
it("patch() makes HTTP PATCH request", () => {
58+
tc.patch("/");
59+
expect(tc.statusCode()).toBeGT(0);
60+
});
61+
62+
it("delete() makes HTTP DELETE request", () => {
63+
tc.delete("/");
64+
expect(tc.statusCode()).toBeGT(0);
65+
});
66+
67+
it("visit() is alias for get()", () => {
68+
tc.visit("/");
69+
expect(tc.statusCode()).toBeGT(0);
70+
});
71+
72+
});
73+
74+
describe("assertions", () => {
75+
76+
it("assertStatus() passes on correct status code", () => {
77+
tc.get("/");
78+
tc.assertStatus(tc.statusCode());
79+
});
80+
81+
it("assertStatus() fails on wrong status code", () => {
82+
tc.get("/");
83+
expect(function() {
84+
tc.assertStatus(999);
85+
}).toThrow("TestBox.AssertionFailed");
86+
});
87+
88+
it("assertOk() passes on 200 response", () => {
89+
var fc = $fakeClient(status = 200);
90+
fc.assertOk();
91+
});
92+
93+
it("assertNotFound() passes on 404 response", () => {
94+
tc.get("/wheels-nonexistent-route-that-should-404");
95+
tc.assertNotFound();
96+
});
97+
98+
it("assertSee() finds text in response body", () => {
99+
tc.get("/");
100+
expect(Len(tc.content())).toBeGT(0, "Response body should not be empty");
101+
tc.assertSee(Left(tc.content(), 10));
102+
});
103+
104+
it("assertSee() fails when text is not found", () => {
105+
tc.get("/");
106+
expect(function() {
107+
tc.assertSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ");
108+
}).toThrow("TestBox.AssertionFailed");
109+
});
110+
111+
it("assertDontSee() confirms text is absent", () => {
112+
tc.get("/");
113+
tc.assertDontSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ");
114+
});
115+
116+
it("assertDontSee() fails when text is present", () => {
117+
tc.get("/");
118+
expect(Len(tc.content())).toBeGT(0, "Response body should not be empty");
119+
var snippet = Left(tc.content(), 10);
120+
expect(function() {
121+
tc.assertDontSee(snippet);
122+
}).toThrow("TestBox.AssertionFailed");
123+
});
124+
125+
it("assertJson() validates JSON response", () => {
126+
var fc = $fakeClient(body = '{"name":"wheels","version":4}');
127+
fc.assertJson();
128+
fc.assertJson({name: "wheels"});
129+
});
130+
131+
it("assertJson() fails on non-JSON response", () => {
132+
var fc = $fakeClient(body = "<html>not json</html>");
133+
expect(function() {
134+
fc.assertJson();
135+
}).toThrow("TestBox.AssertionFailed");
136+
});
137+
138+
it("assertRedirect() fails on non-redirect status", () => {
139+
tc.get("/");
140+
expect(function() {
141+
tc.assertRedirect();
142+
}).toThrow("TestBox.AssertionFailed");
143+
});
144+
145+
it("assertRedirect() passes on 3xx status", () => {
146+
var fc = $fakeClient(status = 302, headers = {Location: "/dashboard"});
147+
fc.assertRedirect();
148+
fc.assertRedirect(to = "/dashboard");
149+
});
150+
151+
});
152+
153+
describe("request configuration", () => {
154+
155+
it("withHeaders() adds custom headers", () => {
156+
tc.withHeaders({"X-Custom-Test": "hello"});
157+
tc.get("/");
158+
expect(tc.statusCode()).toBeGT(0);
159+
});
160+
161+
it("withHeader() adds a single header", () => {
162+
tc.withHeader("X-Custom-Test", "hello");
163+
tc.get("/");
164+
expect(tc.statusCode()).toBeGT(0);
165+
});
166+
167+
it("asJson() sets content type and returns client for chaining", () => {
168+
var result = tc.asJson();
169+
expect(result).toBeInstanceOf("wheels.wheelstest.TestClient");
170+
});
171+
172+
});
173+
174+
describe("response accessors", () => {
175+
176+
beforeEach(() => {
177+
tc.get("/");
178+
});
179+
180+
it("content() returns response body as string", () => {
181+
expect(tc.content()).toBeString();
182+
});
183+
184+
it("statusCode() returns numeric status", () => {
185+
expect(tc.statusCode()).toBeNumeric();
186+
});
187+
188+
it("headers() returns response headers struct", () => {
189+
expect(tc.headers()).toBeStruct();
190+
});
191+
192+
it("response() returns full response struct", () => {
193+
expect(tc.response()).toBeStruct();
194+
});
195+
196+
});
197+
198+
describe("chaining", () => {
199+
200+
it("supports fluent chaining: assertOk().assertSee()", () => {
201+
var fc = $fakeClient(body = "<html>Welcome to Wheels</html>");
202+
fc.assertOk().assertSee("Welcome");
203+
});
204+
205+
it("supports withHeaders().get().assertStatus() chain", () => {
206+
tc.withHeader("Accept", "text/html").get("/");
207+
tc.assertStatus(tc.statusCode());
208+
});
209+
210+
});
211+
212+
describe("assertSeeInOrder", () => {
213+
214+
it("passes when texts appear in order", () => {
215+
var fc = $fakeClient(body = "alpha beta gamma delta");
216+
fc.assertSeeInOrder(["alpha", "beta", "gamma"]);
217+
});
218+
219+
it("fails when texts appear out of order", () => {
220+
var fc = $fakeClient(body = "alpha beta gamma delta");
221+
expect(function() {
222+
fc.assertSeeInOrder(["gamma", "alpha"]);
223+
}).toThrow("TestBox.AssertionFailed");
224+
});
225+
226+
});
227+
228+
describe("assertJsonPath", () => {
229+
230+
it("resolves dot-notation paths in JSON", () => {
231+
var fc = $fakeClient(body = '{"user":{"name":"John","roles":["admin","editor"]}}');
232+
fc.assertJsonPath("user.name", "John");
233+
});
234+
235+
it("resolves 1-based array indices in JSON", () => {
236+
var fc = $fakeClient(body = '{"items":["a","b","c"]}');
237+
fc.assertJsonPath("items.1", "a");
238+
fc.assertJsonPath("items.3", "c");
239+
});
240+
241+
it("fails on missing path", () => {
242+
var fc = $fakeClient(body = '{"name":"wheels"}');
243+
expect(function() {
244+
fc.assertJsonPath("missing.key", "val");
245+
}).toThrow("TestBox.AssertionFailed");
246+
});
247+
248+
});
249+
250+
describe("assertHeader", () => {
251+
252+
it("passes when header exists", () => {
253+
tc.get("/");
254+
tc.assertHeader("Content-Type");
255+
});
256+
257+
it("passes when header matches value", () => {
258+
var fc = $fakeClient(headers = {"X-Custom": "hello"});
259+
fc.assertHeader("X-Custom", "hello");
260+
});
261+
262+
it("fails when header is missing", () => {
263+
tc.get("/");
264+
expect(function() {
265+
tc.assertHeader("X-Nonexistent-Header-For-Test");
266+
}).toThrow("TestBox.AssertionFailed");
267+
});
268+
269+
});
270+
271+
describe("post with body", () => {
272+
273+
it("sends form fields by default", () => {
274+
tc.post("/", {testField: "testValue"});
275+
expect(tc.statusCode()).toBeGT(0);
276+
});
277+
278+
it("sends JSON body when asJson()", () => {
279+
tc.asJson().post("/", {testField: "testValue"});
280+
expect(tc.statusCode()).toBeGT(0);
281+
});
282+
283+
});
284+
285+
});
286+
287+
}
288+
289+
}

0 commit comments

Comments
 (0)