Skip to content

Instantly share code, notes, and snippets.

@michaelbromley
Last active July 9, 2024 04:24
Show Gist options
  • Save michaelbromley/bb4291200c25196507d12d2fd948a13e to your computer and use it in GitHub Desktop.
Save michaelbromley/bb4291200c25196507d12d2fd948a13e to your computer and use it in GitHub Desktop.
Automatic component mocking in Angular
import {Component, EventEmitter, Type} from '@angular/core';
type MetadataName = 'Input' | 'Output';
interface PropDecoratorFactory {
ngMetadataName: MetadataName;
bindingPropertyName: string | undefined;
}
interface PropMetadata { [key: string]: PropDecoratorFactory[]; }
/**
* Experimental decorator for creating a mock component.
*
* WARNING: this uses internal, non-public API properties which could change with any new Angular
* release, and therefore cannot be considered stable.
*/
function MockComponentDecorator(original: Type<any>): ClassDecorator {
return target => {
const selector = (original as any).__annotations__[0].selector;
const props: PropMetadata = (original as any).__prop__metadata__;
const inputs = filterMetadataBy(props, 'Input');
const outputs = filterMetadataBy(props, 'Output');
inputs.forEach(input => target.prototype[input.key] = null);
outputs.forEach(output => target.prototype[output.key] = new EventEmitter<any>());
for (const prop in original.prototype) {
if (prop !== 'constructor') {
target.prototype[prop] = jasmine.createSpy(`Mock_${original.name}#${prop}`);
}
}
Component({
selector,
template: `!!Mock_${original.name}!!`,
inputs: toMetadataStrings(inputs),
outputs: toMetadataStrings(outputs)
})(target);
};
}
function filterMetadataBy(props: PropMetadata, decorator: MetadataName): { key: string; decorator: PropDecoratorFactory; }[] {
return Object.keys(props)
.map(key => ({ key, decorator: props[key][0] }))
.filter(input => input.decorator.ngMetadataName === decorator);
}
function toMetadataStrings(input: { key: string; decorator: PropDecoratorFactory; }[] ): string[] {
return input.map(i => i.decorator.bindingPropertyName ? `${i.key}: ${i.decorator.bindingPropertyName}` : i.key);
}
/**
* Takes a BaseComponent class and returns a mock component, decorated with the correct @Component metadata,
* so all the Inputs and Outputs of the BaseComponent are correctly configured and all methods are stubbed as Jasmine spies.
*/
export function createMock<T extends Type<any>>(BaseComponent: T): T {
@MockComponentDecorator(BaseComponent)
class MockComponent {}
return MockComponent as any;
}
import {TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import 'createMock' from './create-mock';
import 'ComplexComponent' from './path/to/complex.component';
import 'ComponentUnderTest' from './component-under-test.component';
// Assume ComponentUnderTest uses ComplexComponent in its template, but
// ComplexComponent is very complex, has many dependencies and we really don't want to use the
// real component as it really complicates our test code.
// We create a mock of ComplexComponent which will have an identical public API
// but no implementation beyond jasmine spies and EventEmitters for outputs.
// So e.g. no providers will have to be configured for its dependencies,
// & none of its sub-components will have to be declared.
class MockComplexComponent extends createMock(ComplexComponent) {}
describe('ComponentUnderTest', () => {
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
ComponentUnderTest,
// We declare the mock rather than the original
MockComplexComponent
]
});
});
it('does stuff with ComplexComponent', () => {
const fixture = TestBed.createComponent(ComponentUnderTest);
const complexComponent: MockComplexComponent = fixture.debugElement.query(By.directive(MockComplexComponent)).componentInstance;
// We can now make assertions on the methods of ComplexComponent
expect(complexComponent.someMethod).not.toHaveBeenCalled();
// We can also emit events from ComplexComponent and test that ComponentUnderTest
// reacts to them in the expected way.
complexComponent.someEvent.emit('foo');
});
]);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment