Challenge #2: Stubbing Behavior
In the last challenge, you made createNull()
inject an instance of the StubbedHttp
class. StubbedHttp
isn’t implemented yet, so the test is failing. In this challenge, you’ll implement StubbedHttp
.
Instructions
1. Analyze the code:
- Mark the
"performs request"
test withit.only()
so it’s the only test that is run. - Use
console.log()
to instrument the code. The"performs request"
test should write the following output to the console:BEFORE SEND_REQUEST BEFORE HTTP.REQUEST BEFORE CLIENT_REQUEST.END BEFORE HANDLE_RESPONSE START HANDLE_RESPONSE_ASYNC INSIDE HANDLE_RESPONSE_ASYNC PROMISE INSIDE RESPONSE EVENT INSIDE DATA EVENT INSIDE END EVENT
- Trace through the code to understand how
this._http
and its return values are used.
2. Implement the stub:
- Remove the
.only()
from the"performs request"
test and put it on the"doesn't talk to network"
test instead. - Use the console logs to understand what’s missing from the
StubbedHttp
class. - Iteratively modify the
StubbedHttp
class, using the logs as a guide, until the test passes. Implement as little code as possible. (The Creating Event Emitters primer will be helpful here.) - Remove the instrumentation, including the
"STUB CALLED"
log.
Remember to commit your changes when you’re done.
Understanding HttpClient
These are the methods used by HttpClient
:
const clientRequest = this._http.request({ host, port, method, path, headers });
Start making an HTTP request. Note that the request isn’t complete until request.end()
is called.
- host
(string)
- the server host - port
(number)
- the server port - method
(string)
- the HTTP method - path
(string)
- the HTTP path - optional headers
(object)
- the HTTP headers; defaults to none - returns clientRequest
(http.ClientRequest)
- the request
clientRequest.end(body);
Complete the HTTP request by sending the request body (if any). This function returns immediately, before all the data has been sent.
Important! body
will be ignored if the HTTP method can’t have a body. For example, GET
requests can’t have a body, but POST
requests can.
- optional body
(string)
- the request body
clientRequest.on("error", fn);
Run fn
when the client is unable to make the request, such as when the connection is refused.
- fn
((err) => void)
- the function to run - err
(Error)
- the error that occurred
clientRequest.on("response", fn);
Run fn
when the client receives a response from the server.
- fn
((clientResponse) => void)
- the function to run - clientResponse
(http.IncomingMessage)
- the client’s view of the server’s response
clientResponse.on("data", fn);
Run fn
when the client receives body data from the server. Note that the server can send multiple pieces of data, so fn
can be called multiple times. Accumulate it into a string like this:
This is a replacement for clientResponse.resume()
let data = "";
clientResponse.on("data", (chunk) => {
data += chunk;
}
- fn
((chunk) => void)
- the function to run - chunk
(Buffer)
- the data
clientResponse.on("end", fn);
Run fn
when the client sees the end of the server‘s response. Important: for this event to occur, you must consume the response’s data. You can use clientResponse.resume()
to do so.
- fn
((clientResponse) => void)
- the function to run - clientResponse
(http.IncomingMessage)
- the client’s view of the server’s response
const status = clientResponse.statusCode;
The response’s status code.
- returns status
(number)
- the status code
const headers = clientResponse.headers;
The response’s headers. Header names and values are mapped to object names and values.
- returns headers
(object)
- the headers
API Documentation
it.only(async () => { ... });
Tell the test runner to only run tests that use it.only()
. This is handy for debugging and analysis: you can add console.log()
statements to the production code and then mark a single test with it.only()
, ensuring that the logging only happens once.
class MyClass extends EventEmitter { ... }
Extend EventEmitter
to add support for the on()
and emit()
event handling methods to a class. This is useful when creating stubs of classes that emit events, such as clientRequest
.
Important! If your code has a constructor, the constructor must call super()
as its first line.
this.emit(eventName, eventValue);
Emits eventName
, which will run fn()
on any code that has previously run on(eventName, fn)
on the same object.
Best used in conjunction with setImmediate()
to emit events asynchronously.
- eventName
(string)
- the function to run
setImmediate(fn);
Run fn()
later, after the current code is complete or await
ing a promise that has yet to resolve. Practically speaking, this function can be used to emulate events that occur asynchronously, such as clientRequest.on("response", ...)
. Use it like this:
setImmediate(() => {
this.emit("response", myResponse);
}
- fn
(function)
- the function to run
TypeScript Types
response.on("data", (chunk: Buffer) => ...)
The chunk
provided to the data
event is a Buffer
.
JavaScript Primers
Hints
Analyzing execution:
1
Start by analyzing the HttpClient
code. How does the flow of execution work? (You might need to review the Using Promises, Creating Promises, and Using Events primers.)
You can understand it better adding logging statements and marking a test it.only()
.
Try marking the "performs request"
test with it.only()
.
Test code:
it.only("performs request", async () => {
// ...
});
Production code:
async requestAsync({ host, port, method, path, headers = {}, body = ""}) {
if (method.toLowerCase() === "get" && body !== "") {
throw new Error("Don't include body with GET requests; Node won't send it");
}
console.log("BEFORE SEND_REQUEST");
const clientRequest = this.#sendRequest(host, port, method, path, headers, body);
console.log("BEFORE HANDLE_RESPONSE");
return await this.#handleResponseAsync(clientRequest);
}
#sendRequest(host, port, method, path, headers, body) {
console.log("BEFORE HTTP.REQUEST");
const clientRequest = this._http.request({
host: host,
port: port,
method: method,
path: path,
headers: headers,
});
console.log("BEFORE CLIENT_REQUEST.END");
clientRequest.end(body);
return clientRequest;
}
async #handleResponseAsync(clientRequest) {
console.log("START HANDLE_RESPONSE_ASYNC");
return await new Promise((resolve, reject) => {
console.log("INSIDE HANDLE_RESPONSE_ASYNC PROMISE");
clientRequest.on("error", (err) => reject(err));
clientRequest.on("response", (clientResponse) => {
console.log("INSIDE RESPONSE EVENT");
let body = "";
clientResponse.on("data", (chunk) => {
console.log("INSIDE DATA EVENT");
body += chunk;
});
clientResponse.on("end", () => {
console.log("INSIDE END EVENT");
resolve({
status: clientResponse.statusCode, // add 'as number' for TypeScript
headers: clientResponse.headers, // add 'as HttpHeaders' for TypeScript
body,
});
});
});
});
}
The test output looks like this:
BEFORE SEND_REQUEST
BEFORE HTTP.REQUEST
BEFORE CLIENT_REQUEST.END
BEFORE HANDLE_RESPONSE
START HANDLE_RESPONSE_ASYNC
INSIDE HANDLE_RESPONSE_ASYNC PROMISE
INSIDE RESPONSE EVENT
INSIDE DATA EVENT
INSIDE END EVENT
2
Now that you know what the flow looks like when it’s working normally, look at what it looks like when using HttpClient.createNull()
.
Remove the it.only()
from the "performs request"
test and put it on the "doesn't talk to network"
test instead.
Test code:
it("performs request", async () => {
// ...
});
// ...
it.only("doesn't talk to network", async () => {
// ...
});
Test output:
BEFORE SEND_REQUEST
BEFORE HTTP.REQUEST
STUB CALLED
BEFORE CLIENT_REQUEST.END
BEFORE HANDLE_RESPONSE
:
3
The code isn’t logging BEFORE HANDLE_RESPONSE
. Why not?
Look at the stack trace. Why is the test failing?
It’s complaining that end()
is being called on undefined
.
It’s crashing on the line that reads clientRequest.end()
.
The code isn’t logging BEFORE HANDLE_RESPONSE
because clientRequest
is undefined
, which is causing the code to crash before #sendRequest
returns.
4
Why is clientRequest
undefined
?
Where does it come from?
It’s defined in the same function.
It’s returned by this._http.request()
.
clientRequest
is undefined
because this._http.request()
isn’t returning a value.
5
this._http.request()
isn’t returning a value. Why not?
It’s only a problem in the nulled instance
test.
That test is calling HttpClient.createNull()
.
HttpClient.createNull()
is setting this._http
to an instance of StubbedHttp
.
this._http.request()
isn’t returning a value because StubbedHttp.request()
isn’t returning a value.
6
What should StubbedHttp.request()
return?
What does the real http
module return?
It returns clientRequest
, which is an http.ClientRequest
.
Using real-world infrastructure classes like http.ClientRequest
is always messy. What could you do instead?
You can make a stub.
StubbedHttp.request()
should return a StubbedRequest
.
7
You need a StubbedRequest
class.
It should be returned by StubbedHttp.request()
.
You can remove the "STUB CALLED"
logging.
class StubbedHttp {
request() {
return new StubbedRequest();
}
}
class StubbedRequest {
}
8
The error message should have changed to clientRequest.end is not a function
.
That means clientRequest
is no longer undefined
. Returning StubbedRequest
worked.
But clientRequest.end()
doesn’t exist.
That’s because you haven’t implemented StubbedRequest.end()
.
9
You need to implement StubbedRequest.end()
. What should it do?
What does the real clientRequest.end()
do?
It sends an HTTP request to the server.
But the Nulled HttpClient
doesn’t send requests.
StubbedRequest.end()
shouldn’t do anything.
10
Implement StubbedRequest.end()
.
It doesn’t need to do anything.
class StubbedRequest {
end() {
}
}
INSIDE RESPONSE EVENT
:
11
The test has made more progress, but now it isn’t logging INSIDE RESPONSE_EVENT
. Why not?
Look at the stack trace. Why is the test failing?
It’s complaining that clientRequest.on is not a function
.
That’s because you haven’t implemented StubbedRequest.on()
.
12
StubbedRequest
needs to implement on()
. What’s the best way to do that?
on()
is part of Node.js’s standard event handling behavior.
It’s not infrastructure code, so it’s okay to use it in a stub.
You can add event handling behavior to a class by by extending EventEmitter
.
class StubbedRequest extends EventEmitter {
end() {
}
}
13
Now the test is timing out. Why?
Which log message is supposed to be written next?
It’s INSIDE RESPONSE EVENT
.
That message isn’t being written because the response
event isn’t firing.
StubbedRequest
needs to emit the response
event.
14
StubbedRequest
needs to emit the response
event. Where should it do it?
When does the event normally fire?
It normally fires after the server receives the request.
When does the server receive the request?
After clientRequest.end()
executes.
StubbedRequest
should emit the response
event inside clientRequest.end()
.
15
Make StubbedRequest
emit the response
event.
You can use this.emit()
to do that.
It should simulate the real code’s asynchronous behavior, and emit the event after clientRequest.on()
is called.
You can use setImmediate()
to delay the call to this.emit()
. (It uses an arrow function.)
class StubbedRequest extends EventEmitter {
end() {
setImmediate(() => {
this.emit("response");
});
}
}
INSIDE DATA EVENT:
16
Now the test isn’t logging INSIDE DATA EVENT
. Why not?
Look at the stack trace. Why is the test failing?
It’s complaining that on()
is being called on undefined
.
It’s crashing on the line that calls clientResponse.on("data", ...)
.
The code isn’t logging INSIDE_DATA_EVENT
because clientResponse
is undefined
.
17
Why is clientResponse
undefined
?
Where does it come from?
It’s received by the clientRequest.on("request", clientResponse)
event handler.
That event is being emitted in StubbedRequest.end()
.
clientResponse
is undefined
because StubbedRequest.end()
isn’t emitting a value with the "response"
event.
18
What value should StubbedRequest.end()
emit?
What does the real clientRequest
emit?
It emits clientResponse
, which is an http.IncomingMessage
.
Using real-world infrastructure classes like http.IncomingMessage
is always messy. What could you do instead?
You can make a stub.
StubbedRequest.end()
should emit a StubbedResponse
.
19
You need a StubbedResponse
class.
It should be emitted by StubbedRequest.end()
.
class StubbedRequest extends EventEmitter {
end() {
setImmediate(() => {
this.emit("response", new StubbedResponse());
});
}
}
class StubbedResponse {
}
20
Now the test is complaining that clientResponse.on is not a function
. Why?
What is clientResponse
?
It’s a StubbedResponse
.
You haven’t implemented StubbedResponse.on()
.
21
What’s the best way to implement StubbedResponse.on()
?
on()
is part of Node.js’s standard event handling behavior.
It’s not infrastructure code, so it’s okay to use it in a stub.
You can add event handling behavior to a class by by extending EventEmitter
.
class StubbedResponse extends EventEmitter {
}
22
Now the test is timing out. Why?
Which log message is supposed to be written next?
It’s INSIDE DATA EVENT
.
That message isn’t being written because the data
event isn’t firing.
StubbedResponse
needs to emit the data
event.
23
StubbedResponse
needs to emit the data
event. Where should it do it?
When does the event normally fire?
It normally fires immediately after the response
event occurs.
StubbedResponse
should emit the data
event when it’s constructed.
24
Make StubbedResponse
emit the data
event.
You can use this.emit()
to do that.
It should simulate the real code’s asynchronous behavior, and emit the event after clientResponse.on("data", ...)
is called.
You can use setImmediate()
to delay the call to this.emit()
.
class StubbedResponse extends EventEmitter {
constructor() {
super();
setImmediate(() => {
this.emit("data");
});
}
}
INSIDE END EVENT
:
25
Now the test is timing out. Why?
Which log message is supposed to be written next?
It’s INSIDE END EVENT
.
That message isn’t being written because the end
event isn’t firing.
StubbedResponse
needs to emit the end
event.
26
StubbedResponse
needs to emit the end
event. Where should it do it?
When does the event normally fire?
It normally fires after the last data
event.
StubbedResponse
should emit the end
event after the data
event.
27
Make StubbedResponse
emit the end
event.
You can use this.emit()
to do that.
It should simulate the real code’s asynchronous behavior, and emit the event after the data
event.
You can use your existing setImmediate()
block.
class StubbedResponse extends EventEmitter {
constructor() {
super();
setImmediate(() => {
this.emit("data");
this.emit("end");
});
}
}
28
The test should pass. It’s time to clean up.
Remove the logs.
Test code:
it("doesn't talk to network", async () => {
// ...
});
Production code:
async requestAsync({ host, port, method, path, headers = {}, body = ""}) {
if (method.toLowerCase() === "get" && body !== "") {
throw new Error("Don't include body with GET requests; Node won't send it");
}
const clientRequest = this.#sendRequest(host, port, method, path, headers, body);
return await this.#handleResponseAsync(clientRequest);
}
#sendRequest(host, port, method, path, headers, body) {
const clientRequest = this._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("error", (err) => reject(err));
clientRequest.on("response", (clientResponse) => {
let body = "";
clientResponse.on("data", (chunk) => {
body += chunk;
});
clientResponse.on("end", () => {
resolve({
status: clientResponse.statusCode, // add 'as number' for TypeScript
headers: clientResponse.headers, // add 'as HttpHeaders' for TypeScript
body,
});
});
});
});
}
TypeScript types (skip these steps if you’re using JavaScript):
29
TypeScript needs a type for this._http
. Call it NodeHttp
.
Don’t use http
’s full interface; instead, just declare the minimum interface that your code actually uses.
You’ve just gone through the effort of figuring out what your code actually uses.
It’s the same interface as StubbedHttp
.
The only difference is that you’ll need to declare all the parameters #sendRequest()
uses.
interface NodeHttp {
request: ({ host, port, method, path, headers }: {
host: string,
port: number,
method: string,
path: string,
headers: HttpHeaders,
}) => NodeHttpRequest,
}
30
TypeScript needs a type for the return value of NodeHttp.request()
. Call it NodeHttpRequest
.
Don’t use http.ClientRequest
’s full interface; instead, just declare the minimum interface that your code actually uses.
It’s the same as StubbedRequest
.
interface NodeHttpRequest extends EventEmitter {
end: (body?: string) => void,
}
31
Now you can use the new types in your code.
Start by replacing the any
in the constructor with NodeHttp
.
constructor(private readonly _http: NodeHttp) {
}
32
After adding NodeHttp
to the constructor, the compiler will complain about missing properties.
It’s complaining about the return type of #sendRequest()
.
The declared return type for #sendRequest()
is too broad.
Replace it with NodeHttpRequest
.
#sendRequest(
host: string,
port: number,
method: string,
path: string,
headers: HttpHeaders,
body: string
): NodeHttpRequest {
const clientRequest = this._http.request({
host: host,
port: port,
method: method,
path: path,
headers: headers,
});
clientRequest.end(body);
return clientRequest;
}
33
After adding NodeHttpRequest
to #sendRequest()
, the compiler will complain about missing properties again.
It’s the same error, just a different line.
One of the declared function parameters for #handleResponseAsync()
is too broad.
Replace http.ClientRequest
with NodeHttpRequest
.
async #handleResponseAsync(clientRequest: NodeHttpRequest): Promise<HttpClientResponse> {
return await new Promise((resolve, reject) => {
clientRequest.on("error", (err) => reject(err));
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,
});
});
});
});
}
34
Now the compiler is complaining about the chunk
parameter.
The correct type is Buffer
.
async #handleResponseAsync(clientRequest: NodeHttpRequest): Promise<HttpClientResponse> {
return await new Promise((resolve, reject) => {
clientRequest.on("error", (err) => reject(err));
clientRequest.on("response", (clientResponse) => {
let body = "";
clientResponse.on("data", (chunk: Buffer) => {
body += chunk;
});
clientResponse.on("end", () => {
resolve({
status: clientResponse.statusCode as number,
headers: clientResponse.headers as HttpHeaders,
body,
});
});
});
});
}
35
The code compiles and passes tests, but there are still two implicit any
types in the code. Where are they?
It’s because TypeScript considers event handlers’ values to have an any
type.
Look for event handlers that take a parameter and don’t have a type declaration.
The clientRequest.on("error", ...)
handler and the clientResponse.on("response", ...)
handlers both have any
types.
36
Declare a type for the clientRequest.on("error", ...)
event handler.
It’s an Error
.
async #handleResponseAsync(clientRequest: NodeHttpRequest): Promise<HttpClientResponse> {
return await new Promise((resolve, reject) => {
clientRequest.on("error", (err: Error) => reject(err));
clientRequest.on("response", (clientResponse) => {
let body = "";
clientResponse.on("data", (chunk: Buffer) => {
body += chunk;
});
clientResponse.on("end", () => {
resolve({
status: clientResponse.statusCode as number,
headers: clientResponse.headers as HttpHeaders,
body,
});
});
});
});
}
37
Declare a type for the clientResponse.on("response", ...)
event handler.
You’ll need to create a new interface named NodeHttpResponse
.
Create the minimum interface needed, based on what StubbedResponse
and #handleResponseAsync()
actually use.
Extend EventEmitter
and declare statusCode
and headers
.
interface NodeHttpResponse extends EventEmitter {
statusCode: number,
headers: HttpHeaders,
}
// ...
async #handleResponseAsync(clientRequest: NodeHttpRequest): Promise<HttpClientResponse> {
return await new Promise((resolve, reject) => {
clientRequest.on("error", (err: Error) => reject(err));
clientRequest.on("response", (clientResponse: NodeHttpResponse) => {
let body = "";
clientResponse.on("data", (chunk: Buffer) => {
body += chunk;
});
clientResponse.on("end", () => {
resolve({
status: clientResponse.statusCode as number,
headers: clientResponse.headers as HttpHeaders,
body,
});
});
});
});
}
38
For completeness, one more set of types needs to be declared.
The stubs don’t implement the new interfaces.
Modify StubbedHttp
, StubbedNodeRequest
, and StubbedNodeResponse
to implement NodeHttp
, NodeHttpRequest
, and NodeHttpResponse
respectively.
You’ll have to add placeholders for status
and headers
to NodeHttpResponse
. (They’ll be implemented in the next challenge.)
class StubbedHttp implements NodeHttp {
request() {
return new StubbedRequest();
}
}
class StubbedRequest extends EventEmitter implements NodeHttpRequest {
end() {
setImmediate(() => {
this.emit("response", new StubbedResponse());
});
}
}
class StubbedResponse extends EventEmitter implements NodeHttpResponse {
statusCode = 42;
headers = {};
constructor() {
super();
setImmediate(() => {
this.emit("data");
this.emit("end");
});
}
}
Complete Solution
Production code (JavaScript):
class StubbedHttp {
request() {
return new StubbedRequest();
}
}
class StubbedRequest extends EventEmitter {
end() {
setImmediate(() => {
this.emit("response", new StubbedResponse());
});
}
}
class StubbedResponse extends EventEmitter {
constructor() {
super();
setImmediate(() => {
this.emit("data");
this.emit("end");
});
}
}
Production code (TypeScript):
interface NodeHttp {
request: ({ host, port, method, path, headers }: {
host: string,
port: number,
method: string,
path: string,
headers: HttpHeaders,
}) => NodeHttpRequest,
}
interface NodeHttpRequest extends EventEmitter {
end: (body?: string) => void,
}
interface NodeHttpResponse extends EventEmitter {
statusCode: number,
headers: HttpHeaders,
}
export class HttpClient {
// ...
constructor(private readonly _http: NodeHttp) {
}
// ...
#sendRequest(
host: string,
port: number,
method: string,
path: string,
headers: HttpHeaders,
body: string
): NodeHttpRequest {
const clientRequest = this._http.request({
host: host,
port: port,
method: method,
path: path,
headers: headers,
});
clientRequest.end(body);
return clientRequest;
}
async #handleResponseAsync(clientRequest: NodeHttpRequest): Promise<HttpClientResponse> {
return await new Promise((resolve, reject) => {
clientRequest.on("error", (err: Error) => reject(err));
clientRequest.on("response", (clientResponse: NodeHttpResponse) => {
let body = "";
clientResponse.on("data", (chunk: Buffer) => {
body += chunk;
});
clientResponse.on("end", () => {
resolve({
status: clientResponse.statusCode as number,
headers: clientResponse.headers as HttpHeaders,
body,
});
});
});
});
}
// ...
}
class StubbedHttp implements NodeHttp {
request() {
return new StubbedRequest();
}
}
class StubbedRequest extends EventEmitter implements NodeHttpRequest {
end() {
setImmediate(() => {
this.emit("response", new StubbedResponse());
});
}
}
class StubbedResponse extends EventEmitter implements NodeHttpResponse {
statusCode = 42; // placeholder
headers = {}; // placeholder
constructor() {
super();
setImmediate(() => {
this.emit("data");
this.emit("end");
});
}
}
Test code is unchanged.