Challenge #12: Happy Path

You’ve successfully test-driven low-level infrastructure code with narrow integration tests. In this challenge, you’ll wrap up by finishing the “happy path” tests.

Instructions

  1. Implement the "returns response" test to check that httpClient.requestAsync() returns the server’s response. Use the existing assertion in the "performs request" test.
  2. Modify the "performs request" test to only check that httpClient.requestAsync() sends the correct request. Use the existing assertion.
  3. Implement the "headers and body are optional" test. Assert that the server receives no headers and an empty body when httpClient.requestAsync() receives an undefined headers and body parameters.

Remember to commit your changes when you’re done.

API Documentation

No new methods.

JavaScript Primers

No new concepts.

Hints

1
Start with the "returns response" test.
The code you need is already in the "performs request" test.
You can copy that test and delete the parts that aren’t relevant.
The requestAsync() parameters aren’t relevant.
Neither is the spyServer.lastRequest assertion.
it("returns response", async () => {
	spyServer.setResponse({
		status: 999,
		headers: {
			myResponseHeader: "myResponseValue",
		},
		body: "my response body"
	});

	const { response } = await requestAsync();

	// removed spyServer.lastRequest assertion

	delete response.headers.date;
	assert.deepEqual(response, {
		status: 999,
		headers: {
			myresponseheader: "myResponseValue",
			connection: "close",
			"content-length": "16",
		},
		body: "my response body",
	});
});
2
Modify the "performs request" test.
You only need to delete the parts that aren’t relevant.
The code related to the response isn’t relevant.
You don’t need the spyServer.setResponse() call or the response assertion.
it("performs request", async () => {
	// removed spyServer.setResponse() call

	await requestAsync({
		host: HOST,
		port: PORT,
		method: "POST",
		path: "/my/path",
		headers: {
			myRequestHeader: "myRequestValue",
		},
		body: "my request body",
	});

	assert.deepEqual(spyServer.lastRequest, {
		method: "POST",
		path: "/my/path",
		headers: {
			myrequestheader: "myRequestValue",
			host: `${HOST}:${PORT}`,
			connection: "close",
			"content-length": "15",
		},
		body: "my request body",
	});

	// removed response assertion
});
3
Implement the "headers and body are optional" test.
1. Call the requestAsync() helper with undefined headers and body.
2. Assert that the headers and body are empty, other than Node’s automatically-generated headers.
You’ll have to specify the method and path too, because they’ll be part of the assertion.
it("headers and body are optional", async () => {
	await requestAsync({
		method: "GET",
		path: "/my/new/path",
		headers: undefined,
		body: undefined,
	});

	assert.deepEqual(spyServer.lastRequest, {
		method: "GET",
		path: "/my/new/path",
		headers: {
			"connection": "close",
			"host": `${HOST}:${PORT}`,
		},
		body: "",
	});
});

The test should pass immediately, without requiring changes to the production code.

Complete Solution

Test code (JavaScript):
describe.only("HTTP Client", () => {

	let spyServer;

	before(async () => {
		spyServer = new SpyServer();
		await spyServer.startAsync();
	});

	beforeEach(() => {
		spyServer.reset();
	});

	after(async () => {
		await spyServer.stopAsync();
	});

	describe("happy path", () => {

		it("performs request", async () => {
			await requestAsync({
				host: HOST,
				port: PORT,
				method: "POST",
				path: "/my/path",
				headers: {
					myRequestHeader: "myRequestValue",
				},
				body: "my request body",
			});

			assert.deepEqual(spyServer.lastRequest, {
				method: "POST",
				path: "/my/path",
				headers: {
					myrequestheader: "myRequestValue",
					host: `${HOST}:${PORT}`,
					connection: "close",
					"content-length": "15",
				},
				body: "my request body",
			});
		});

		it("returns response", async () => {
			spyServer.setResponse({
				status: 999,
				headers: {
					myResponseHeader: "myResponseValue",
				},
				body: "my response body"
			});

			const { response } = await requestAsync();

			delete response.headers.date;
			assert.deepEqual(response, {
				status: 999,
				headers: {
					myresponseheader: "myResponseValue",
					connection: "close",
					"content-length": "16",
				},
				body: "my response body",
			});
		});

		it("headers and body are optional", async () => {
			await requestAsync({
				method: "GET",
				path: "/my/new/path",
				headers: undefined,
				body: undefined,
			});

			assert.deepEqual(spyServer.lastRequest, {
				method: "GET",
				path: "/my/new/path",
				headers: {
					"connection": "close",
					"host": `${HOST}:${PORT}`,
				},
				body: "",
			});
		});

	});

	// ...
});

async function requestAsync({
	host = HOST,
	port = PORT,
	method = "GET",
	path = "/irrelevant/path",
	headers = undefined,
	body = undefined,
} = {}) {
	const client = HttpClient.create();
	const response = await client.requestAsync({ host, port, method, path, headers, body });
	return { response };
}


class SpyServer {

	constructor() {
		this.reset();
	}

	reset() {
		this.lastRequest = null;
		this.setResponse({
			status: 501,
			headers: {},
			body: "SpyServer response not specified",
		});
	}

	setResponse(response) {
		this._response = response;
	}

	async startAsync() {
		this._server = http.createServer();

		await new Promise((resolve, reject) => {
			this._server.listen(PORT);
			this._server.on("listening", () => resolve());
		});

		this._server.on("request", (serverRequest, serverResponse) => {
			let body = "";
			serverRequest.on("data", (chunk) => {
				body += chunk;
			});

			serverRequest.on("end", () => {
				this.lastRequest = {
					method: serverRequest.method,
					path: serverRequest.url,
					headers: serverRequest.headers,
					body,
				};

				serverResponse.statusCode = this._response.status;
				Object.entries(this._response.headers).forEach(([ key, value ]) => {
					serverResponse.setHeader(key, value);
				});
				serverResponse.end(this._response.body);
			});
		});
	}

	async stopAsync() {
		await new Promise((resolve, reject) => {
			this._server.close();
			this._server.on("close", () => resolve());
		});
	}

}
Test code (TypeScript):
describe.only("HTTP Client", () => {

	let spyServer: SpyServer;

	before(async () => {
		spyServer = new SpyServer();
		await spyServer.startAsync();
	});

	beforeEach(() => {
		spyServer.reset();
	});

	after(async () => {
		await spyServer.stopAsync();
	});

	describe("happy path", () => {

		async function requestAsync({
			host = HOST,
			port = PORT,
			method = "GET",
			path = "/irrelevant/path",
			headers = {},
			body = "irrelevant body",
		} = {}) {
			const client = HttpClient.create();
			return await client.requestAsync({ host, port, method, path, headers, body });
		}

		it("performs request", async () => {
			await requestAsync({
				host: HOST,
				port: PORT,
				method: "POST",
				path: "/my/path",
				headers: {
					myRequestHeader: "myRequestValue",
				},
				body: "my request body",
			});

			assert.deepEqual(spyServer.lastRequest, {
				method: "POST",
				path: "/my/path",
				headers: {
					myrequestheader: "myRequestValue",
					host: `${HOST}:${PORT}`,
					connection: "close",
					"content-length": "15",
				},
				body: "my request body",
			});
		});

		it("returns response", async () => {
			spyServer.setResponse({
				status: 999,
				headers: {
					myResponseHeader: "myResponseValue",
				},
				body: "my response body"
			});

			const { response } = await requestAsync();

			delete response.headers.date;
			assert.deepEqual(response, {
				status: 999,
				headers: {
					myresponseheader: "myResponseValue",
					connection: "close",
					"content-length": "16",
				},
				body: "my response body",
			});
		});

		it("headers and body are optional", async () => {
			await requestAsync({
				method: "GET",
				path: "/my/new/path",
				headers: undefined,
				body: undefined,
			});

			assert.deepEqual(spyServer.lastRequest, {
				method: "GET",
				path: "/my/new/path",
				headers: {
					"connection": "close",
					"host": `${HOST}:${PORT}`,
				},
				body: "",
			});
		});
	});

	// ...
});

async function requestAsync({
	host = HOST,
	port = PORT,
	method = "GET",
	path = "/irrelevant/path",
	headers = {},
	body = "irrelevant body",
} = {}) {
	const client = HttpClient.create();
	const response = await client.requestAsync({ host, port, method, path, headers, body });
	return { response };
}


interface SpyServerRequest {
	method: string,
	path: string,
	headers: NodeIncomingHttpHeaders,
	body: string,
}

interface SpyServerResponse {
	status: number,
	headers: HttpHeaders,
	body: string,
}

class SpyServer {

	_server?: http.Server;
	_response!: SpyServerResponse;
	lastRequest!: SpyServerRequest | null;

	constructor() {
		this.reset();
	}

	reset() {
		this.lastRequest = null;
		this.setResponse({
			status: 501,
			headers: {},
			body: "SpyServer response not specified",
		});
	}

	setResponse(response: SpyServerResponse) {
		this._response = response;
	}

	async startAsync() {
		await new Promise<void>((resolve, reject) => {
			this._server = http.createServer();

			this._server.listen(PORT);
			this._server.on("listening", () => resolve());

			this._server.on("request", (serverRequest, serverResponse) => {
				let body = "";
				serverRequest.on("data", (chunk) => {
					body += chunk;
				});

				serverRequest.on("end", () => {
					this.lastRequest = {
						method: serverRequest.method,
						path: serverRequest.url,
						headers: serverRequest.headers,
						body,
					} as SpyServerRequest;

					serverResponse.statusCode = this._response.status;
					Object.entries(this._response.headers).forEach(([ key, value ]) => {
						serverResponse.setHeader(key, value);
					});
					serverResponse.end(this._response.body);
				});
			});
		});
	}

	async stopAsync() {
		await new Promise<void>((resolve, reject) => {
			if (this._server === undefined) return reject(new Error("SpyServer has not been started"));

			this._server.close();
			this._server.on("close", () => resolve());
		});
	}

}
Production code (JavaScript):
export class HttpClient {
	// ...

	async requestAsync({ host, port, method, path, headers, body }) {
		const clientRequest = this.#sendRequest(host, port, method, path, headers, body);
		return await this.#handleResponseAsync(clientRequest);
	}

	#sendRequest(host, port, method, path, headers, body) {
		const clientRequest = http.request({
			host: host,
			port: port,
			method: method,
			path: path,
			headers: headers,
		});
		clientRequest.end(body);

		return clientRequest;
	}

	async #handleResponseAsync(clientRequest) {
		return await new Promise((resolve, reject) => {
			clientRequest.on("response", (clientResponse) => {
				let body = "";
				clientResponse.on("data", (chunk) => {
					body += chunk;
				});

				clientResponse.on("end", () => {
					resolve({
						status: clientResponse.statusCode,
						headers: clientResponse.headers,
						body,
					});
				});
			});
		});
	}
}
Production code (TypeScript):
export class HttpClient {
	// ...

	async requestAsync(
		{ host, port, method, path, headers, body }: HttpClientRequestParameters
	): Promise<HttpClientResponse> {
		const clientRequest = this.#sendRequest(host, port, method, path, headers, body);
		return await this.#handleResponseAsync(clientRequest);
	}

	#sendRequest(
		host: string,
		port: number,
		method: string,
		path: string,
		headers?: HttpHeaders,
		body?: string
	): http.ClientRequest {
		const clientRequest = http.request({
			host: host,
			port: port,
			method: method,
			path: path,
			headers: headers,
		});
		clientRequest.end(body);

		return clientRequest;
	}

	async #handleResponseAsync(clientRequest: http.ClientRequest): Promise<HttpClientResponse> {
		return await new Promise((resolve, reject) => {
			clientRequest.on("response", (clientResponse) => {
				let body = "";
				clientResponse.on("data", (chunk) => {
					body += chunk;
				});

				clientResponse.on("end", () => {
					resolve({
						status: clientResponse.statusCode as number,
						headers: clientResponse.headers as HttpHeaders,
						body,
					});
				});
			});
		});
	}

	// ...
}

Bonus challenges

Return to module overview