Last active
July 9, 2024 04:24
-
-
Save michaelbromley/bb4291200c25196507d12d2fd948a13e to your computer and use it in GitHub Desktop.
Automatic component mocking in Angular
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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