Skip to content

Commit

Permalink
feat(ng-dev): add expectRequest() and SlTestRequest
Browse files Browse the repository at this point in the history
  • Loading branch information
ersimont committed Oct 2, 2021
1 parent 3c2f190 commit 115b757
Show file tree
Hide file tree
Showing 4 changed files with 478 additions and 0 deletions.
176 changes: 176 additions & 0 deletions projects/ng-dev/src/lib/test-requests/expect-request.spec.ts
@@ -0,0 +1,176 @@
import { HttpClient, HttpRequest } from '@angular/common/http';
import { HttpTestingController } from '@angular/common/http/testing';
import { expectTypeOf } from 'expect-type';
import { AngularContext } from '../angular-context';
import { expectSingleCallAndReset } from '../spies';
import { expectRequest, HttpBody } from './expect-request';
import { SlTestRequest } from './sl-test-request';

describe('expectRequest()', () => {
let ctx: AngularContext;
let http: HttpClient;
beforeEach(() => {
ctx = new AngularContext();
http = ctx.inject(HttpClient);
});

function cleanUpPendingRequests() {
ctx.inject(HttpTestingController).match(() => true);
}

it('allows optionally declaring the response body type', () => {
ctx.run(() => {
http.get('url 1').subscribe();
http.get('url 2').subscribe();

expectTypeOf(expectRequest<{ a: 1 }>('GET', 'url 1')).toEqualTypeOf<
SlTestRequest<{ a: 1 }>
>();
expectTypeOf(expectRequest('GET', 'url 2')).toEqualTypeOf<
SlTestRequest<HttpBody>
>();
});
});

it('returns a matching SlTestRequest', () => {
ctx.run(() => {
const method = 'GET';
const url = 'a url';
const request = new HttpRequest(method, url);
http.request(request).subscribe();

const req = expectRequest(method, url);

expect(req.request).toBe(request);
});
});

it('matches on method, url, params, headers and body', () => {
ctx.run(async () => {
const method = 'PUT';
const url = 'correct_url';
const body = 'correct_body';
const options = {
body: body,
headers: { header: 'correct' },
params: { param: 'correct' },
};
http.put(url, body, options).subscribe();

expect(() => {
expectRequest('DELETE', url, options);
}).toThrowError();
expect(() => {
expectRequest(method, 'wrong', options);
}).toThrowError();
expect(() => {
expectRequest(method, url, {
...options,
params: { param: 'wrong' },
});
}).toThrowError();
expect(() => {
expectRequest(method, url, {
...options,
headers: { header: 'wrong' },
});
}).toThrowError();
expect(() => {
expectRequest(method, url, { ...options, body: 'wrong' });
}).toThrowError();
expect(() => {
expectRequest(method, url, options);
}).not.toThrowError();
});
});

it('has nice defaults', () => {
ctx.run(async () => {
const method = 'GET';
const url = 'correct_url';
http.get(url).subscribe();

expect(() => {
expectRequest(method, url, { params: { default: 'false' } });
}).toThrowError();
expect(() => {
expectRequest(method, url, { headers: { default: 'false' } });
}).toThrowError();
expect(() => {
expectRequest(method, url, { body: 'not_default' });
}).toThrowError();
expect(() => {
expectRequest(method, url);
}).not.toThrowError();
});
});

it('throws a friendly message when there are no matches', () => {
ctx.run(async () => {
http.get('right').subscribe();
http.get('right').subscribe();

expect(() => {
expectRequest('GET', 'wrong');
}).toThrowError(
`Expected 1 matching request, found 0. See details logged to the console.`,
);
expect(() => {
expectRequest('GET', 'right');
}).toThrowError(
`Expected 1 matching request, found 2. See details logged to the console.`,
);

cleanUpPendingRequests();
});
});

it('logs helpful details when there are no matches', () => {
const log = spyOn(console, 'error');
ctx.run(async () => {
const request1 = new HttpRequest('GET', 'url 1');
const request2 = new HttpRequest('DELETE', 'url 2');
http.request(request1).subscribe();
http.request(request2).subscribe();

expect(() => {
expectRequest('GET', 'bad url');
}).toThrowError();
expectSingleCallAndReset(
log,
'Expected 1 request to match:',
{ method: 'GET', url: 'bad url', params: {}, body: null },
'Actual pending requests:',
[request1, request2],
);

cleanUpPendingRequests();
});
});
});

describe('expectRequest() outside an AngularContext', () => {
it('throws a meaningful error', () => {
expect(() => {
expectRequest('GET', 'a url');
}).toThrowError(
'expectRequest only works while an AngularContext is in use',
);
});
});

describe('expectRequest() example in the docs', () => {
it('works', () => {
const ctx = new AngularContext();
ctx.run(() => {
ctx
.inject(HttpClient)
.get('http://example.com', { params: { key: 'value' } })
.subscribe();
const request = expectRequest<string>('GET', 'http://example.com', {
params: { key: 'value' },
});
request.flush('my response body');
});
});
});
96 changes: 96 additions & 0 deletions projects/ng-dev/src/lib/test-requests/expect-request.ts
@@ -0,0 +1,96 @@
import { HttpRequest } from '@angular/common/http';
import {
HttpTestingController,
TestRequest,
} from '@angular/common/http/testing';
import { assert, mapAsKeys } from '@s-libs/js-core';
import { isEqual } from '@s-libs/micro-dash';
import { AngularContext } from '../angular-context';
import { SlTestRequest } from './sl-test-request';

export type HttpMethod = 'DELETE' | 'GET' | 'POST' | 'PUT';
export type HttpBody = Parameters<TestRequest['flush']>[0];
interface RequestOptions {
params?: Record<string, string>;
headers?: Record<string, string>;
}

/**
* This convenience method is similar to [HttpTestingController.expectOne()]{@linkcode https://angular.io/api/common/http/testing/HttpTestingController}, with extra features. The returned request object will automatically trigger change detection when you flush a response, just like in production.
*
* This method is opinionated in that you must specify all aspects of the request to match. E.g. if the request specifies headers, you must also specify them in the arguments to this method.
*
* This method only works when you are using an {@linkcode AngularContext}.
*
* ```ts
* const ctx = new AngularContext();
* ctx.run(() => {
* ctx
* .inject(HttpClient)
* .get('http://example.com', { params: { key: 'value' } })
* .subscribe();
* const request = expectRequest<string>('GET', 'http://example.com', {
* params: { key: 'value' },
* });
* request.flush('my response body');
* });
* ```
*/
export function expectRequest<ResponseBody extends HttpBody>(
method: HttpMethod,
url: string,
{
params = {},
headers = {},
body = null,
}: RequestOptions & { body?: HttpBody } = {},
): SlTestRequest<ResponseBody> {
expect().nothing(); // convince jasmine we are expecting something

const ctx = AngularContext.getCurrent();
assert(ctx, 'expectRequest only works while an AngularContext is in use');

const pending: HttpRequest<any>[] = [];
let matchCount = 0;
try {
const controller = ctx.inject(HttpTestingController);
return new SlTestRequest(controller.expectOne(isMatch));
} catch (error) {
console.error(
'Expected 1 request to match:',
{ method, url, params, body },
'Actual pending requests:',
pending,
);
throw new Error(
`Expected 1 matching request, found ${matchCount}. See details logged to the console.`,
);
}

function isMatch(req: HttpRequest<any>): boolean {
pending.push(req);
const match =
req.method === method &&
req.url === url &&
matchAngularHttpMap(req.params, params) &&
matchAngularHttpMap(req.headers, headers) &&
isEqual(req.body, body);
if (match) {
++matchCount;
}
return match;
}
}

interface AngularHttpMap {
keys(): string[];
get(key: string): string | null;
}

function matchAngularHttpMap(
actual: AngularHttpMap,
expected: Record<string, string>,
): boolean {
const actualObj = mapAsKeys(actual.keys(), (key) => actual.get(key));
return isEqual(actualObj, expected);
}

0 comments on commit 115b757

Please sign in to comment.