Integration and HTTP Testing
Learning Objectives
- You know of integration testing and know how to test the HTTP interface of a Hono application.
- You know of mocking and stubbing.
While unit testing focuses on testing individual components, integration testing focuses on combining components and testing them as a group.
One approach for integration testing in web applications is testing the HTTP interfaces that the applications provide. This involves sending requests to the application and verifying that the responses match expectations.
Here, we briefly look into testing the HTTP interfaces of a Hono application, and then look into how to how to create stubs that are used to replace functionality of components for testing.
First integration test
Let’s look into testing Hono applications. We use the following deno.json:
{
"imports": {
"@hono/hono": "jsr:@hono/hono@4.8.12",
"@std/assert": "jsr:@std/assert@1.0.15",
"@src/": "./src/"
}
}
And the following app.js, which should be in the src folder.
import { Hono } from "@hono/hono";
const app = new Hono();
app.get("/", (c) => c.json({ message: "Hello World!" }));
export default app;
The above application provides a simple Hono application that responds with a JSON document with the property message that has the value “Hello world!”.
Now, modify the test file tests/app_test.js to match the following.
import { assertEquals } from "@std/assert";
import app from "@src/app.js";
Deno.test("GET / returns { message: 'Hello World!' }", async () => {
const res = await app.request("/");
const body = await res.json();
assertEquals(body, { message: "Hello World!" });
});
Then, run the test using deno test. The output is as follows.
$ deno test
running 1 test from ./tests/app_test.js
GET / returns { message: 'Hello World!' } ... ok (9ms)
running 2 tests from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (0ms)
Message set using setHello returned from getHello ... ok (0ms)
ok | 3 passed | 0 failed (155ms)
Above, we took the Hono application and made a request to it, using Hono’s request-method. The request method accepts either a path for GET requests or a Request object, when making other types of requests. The request-method returns a Response object.
The Request and Response objects are standard objects in the Fetch API. The request object provides methods for working with the request, and the response object provides methods for working with the response.
Hono’s method request effectively allows us to simulate a request to the application, and then assessing whether the response is as expected.
Note that in app.js, we export the application. If we would start the server in app.js using Deno.serve instead of exporting the application, the tests would not have access to the application.
Testing with requests
In the above example, we used the request-method to make a GET request to the application. For other types of requests, and for requests with more information, we create a Request object and pass it to the request-method. When creating a request for testing, the request is made to localhost.
The example below demonstrates writing a test that makes a GET request to the root of the application, similar to the earlier test, but this time by explicitly creating a Request object.
import { assertEquals } from "@std/assert";
import app from "@src/app.js";
Deno.test("GET / returns { message: 'Hello World!' }", async () => {
const request = new Request("http://localhost/");
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello World!" });
});
After the change, the tests still pass successfully.
$ deno test
running 1 test from ./tests/app_test.js
GET / returns { message: 'Hello World!' } ... ok (8ms)
running 2 tests from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (0ms)
Message set using setHello returned from getHello ... ok (0ms)
ok | 3 passed | 0 failed (150ms)
Sending JSON data to server
When working with the Request object, we can also send JSON-formatted data to the server.
Let’s add a new endpoint to our application and test it. The endpoint handles POST requests to the root path of the application, parsing JSON data from the request body, and returning it with an additional message property set to “Hello World!”.
import { Hono } from "@hono/hono";
const app = new Hono();
app.get("/", (c) => c.json({ message: "Hello World!" }));
app.post("/", async (c) => {
const body = await c.req.json();
body.message = "Hello World!";
return c.json(body);
});
export default app;
To test the new endpoint, we create a new test in app_test.js. The test sends a POST request to the application with a JSON object in the request body, and verifies that the response contains the expected data.
import { assertEquals } from "@std/assert";
import app from "@src/app.js";
Deno.test("GET / returns { message: 'Hello World!' }", async () => {
const request = new Request("http://localhost/");
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello World!" });
});
Deno.test("POST / returns JSON object with additional message property", async () => {
const request = new Request("http://localhost/", {
method: "POST",
body: JSON.stringify({ data: "Hello!" }),
});
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello World!", data: "Hello!" });
});
Now, both tests pass.
$ deno test
running 2 tests from ./tests/app_test.js
GET / returns { message: 'Hello World!' } ... ok (14ms)
POST / returns JSON object with additional message property ... ok (1ms)
running 2 tests from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (0ms)
Message set using setHello returned from getHello ... ok (0ms)
ok | 4 passed | 0 failed (179ms)
Mocking and stubbing
A part of integration testing is mocking and stubbing. These techniques are used to simulate the behavior of real objects or functions, often used to test how a function interacts with its dependencies. Mocking involves creating fake objects or functions that simulate the behavior of real objects, while stubbing is a specific type of mocking where you define the behavior of a function or dependency, such as returning a predefined value when called.
Preparing for stubbing
At the moment, we have written the materials so that services export individual functions, which makes mocking and stubbing harder. For example, the helloService.js was written as follows.
let message = "Hello service world!";
const getHello = () => {
return message;
};
const setHello = (newMessage) => {
message = newMessage;
};
export { getHello, setHello };
To make stubbing easier, we need to refactor the file to export an object instead of individual functions, as follows.
let message = "Hello service world!";
const getHello = () => {
return message;
};
const setHello = (newMessage) => {
message = newMessage;
};
export const helloService = { getHello, setHello };
Now, the service would be used as follows.
import { helloService } from "./services/helloService.js";
// e.g. helloService.getHello();
And the helloService_test.js would be modified as follows.
import { assertEquals } from "@std/assert";
import { helloService } from "@src/services/helloService.js";
Deno.test("Function getHello returns 'Hello service world!'", () => {
assertEquals(helloService.getHello(), "Hello service world!");
});
// new test
Deno.test("Message set using setHello returned from getHello", () => {
const hello = helloService.getHello();
helloService.setHello("hello world!");
assertEquals(helloService.getHello(), "hello world!");
helloService.setHello(hello);
});
The materials will be adjusted in the future so that we’ll export objects by default instead of individual files.
Test with a stub
To stub a function from the service, we would use Deno’s stub function. First, modify deno.json to match the following:
{
"imports": {
"@hono/hono": "jsr:@hono/hono@4.8.12",
"@std/assert": "jsr:@std/assert@1.0.15",
"@std/testing": "jsr:@std/testing@1.0.16",
"@src/": "./src/"
}
}
And then, modify helloService_test.js to match the following.
import { assertEquals } from "@std/assert";
import { helloService } from "@src/services/helloService.js";
import { stub } from "@std/testing/mock";
Deno.test("Function getHello returns 'Hello service world!'", () => {
assertEquals(helloService.getHello(), "Hello service world!");
});
Deno.test("Message set using setHello returned from getHello", () => {
const hello = helloService.getHello();
helloService.setHello("hello world!");
assertEquals(helloService.getHello(), "hello world!");
helloService.setHello(hello);
});
// new test
Deno.test("Stubbed getHello", () => {
using getHelloStub = stub(helloService, "getHello", () => "Stubbed Hello!");
assertEquals(helloService.getHello(), "Stubbed Hello!");
});
Now, when we run the tests, we see that all of them pass.
$ deno test
running 2 tests from ./tests/app_test.js
GET / returns { message: 'Hello World!' } ... ok (12ms)
POST / returns JSON object with additional message property ... ok (2ms)
running 3 tests from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (0ms)
Message set using setHello returned from getHello ... ok (0ms)
Stubbed getHello ... ok (0ms)
ok | 5 passed | 0 failed (171ms)
The key part of the above test is the stub function, which replaces the original getHello function with a stub that returns “Stubbed Hello!”. The using keyword ensures that the stub is only active within the scope of the test, and the original function is restored afterward.
Stubbing a function in a separate file
As another example, let’s create a new endpoint /hello that returns the message from the helloService service. First, modify app.js to match the following.
import { Hono } from "@hono/hono";
import { helloService } from "./services/helloService.js";
const app = new Hono();
app.get("/", (c) => c.json({ message: "Hello World!" }));
app.get("/hello", (c) => c.json({ message: helloService.getHello() }));
app.post("/", async (c) => {
const body = await c.req.json();
body.message = "Hello World!";
return c.json(body);
});
export default app;
And then, add a new test in app_test.js to test the new endpoint, as follows.
import { assertEquals } from "@std/assert";
import app from "@src/app.js";
Deno.test("GET / returns { message: 'Hello World!' }", async () => {
const request = new Request("http://localhost/");
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello World!" });
});
Deno.test("POST / returns JSON object with additional message property", async () => {
const request = new Request("http://localhost/", {
method: "POST",
body: JSON.stringify({ data: "Hello!" }),
});
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello World!", data: "Hello!" });
});
// new test
Deno.test("GET /hello returns message from helloService", async () => {
const request = new Request("http://localhost/hello");
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello service world!" });
});
When we run the tests, we see that they pass.
$ deno test
running 3 tests from ./tests/app_test.js
GET / returns { message: 'Hello World!' } ... ok (9ms)
POST / returns JSON object with additional message property ... ok (1ms)
GET /hello returns message from helloService ... ok (0ms)
running 3 tests from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (2ms)
Message set using setHello returned from getHello ... ok (0ms)
Stubbed getHello ... ok (1ms)
ok | 6 passed | 0 failed (229ms)
Let’s now stub the function getHello from the helloService service and add a new test for it:
import { assertEquals } from "@std/assert";
import { stub } from "@std/testing/mock";
import app from "@src/app.js";
import { helloService } from "@src/services/helloService.js";
Deno.test("GET / returns { message: 'Hello World!' }", async () => {
const request = new Request("http://localhost/");
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello World!" });
});
Deno.test("POST / returns JSON object with additional message property", async () => {
const request = new Request("http://localhost/", {
method: "POST",
body: JSON.stringify({ data: "Hello!" }),
});
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello World!", data: "Hello!" });
});
Deno.test("GET /hello returns message from helloService", async () => {
const request = new Request("http://localhost/hello");
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Hello service world!" });
});
// new test
Deno.test("GET /hello returns stubbed hello", async () => {
using getHelloStub = stub(helloService, "getHello", () => "Stubbed Hello!");
const request = new Request("http://localhost/hello");
const res = await app.request(request);
const body = await res.json();
assertEquals(body, { message: "Stubbed Hello!" });
});
Now, when we run the tests, we see that all tests pass.
$ deno test
running 4 tests from ./tests/app_test.js
GET / returns { message: 'Hello World!' } ... ok (9ms)
POST / returns JSON object with additional message property ... ok (1ms)
GET /hello returns message from helloService ... ok (0ms)
GET /hello returns stubbed hello ... ok (1ms)
running 3 tests from ./tests/services/helloService_test.js
Function getHello returns 'Hello service world!' ... ok (0ms)
Message set using setHello returned from getHello ... ok (0ms)
Stubbed getHello ... ok (0ms)
ok | 7 passed | 0 failed (150ms)
Summary
In summary:
- Integration testing focuses on combining components and testing them as a group, while unit testing focuses on individual components in isolation.
- Hono applications can be tested using the
app.request()method, which simulates HTTP requests without starting an actual server — the method accepts either a path string for GET requests or a Request object for other HTTP methods, and returns a Response object. - Mocking and stubbing are techniques for simulating the behavior of dependencies - stubbing replaces a function’s implementation with predefined behavior for testing.