Pure pipes and validators are the easiest Angular code to unit test — they have no side effects, no dependencies, and can often be tested without TestBed at all. A pure pipe’s transform() method is just a function: given input, returns output. Custom form validators are also pure functions: given a FormControl, return either null (valid) or an error object. Testing these directly, without the Angular testing infrastructure, produces the fastest and most focused tests.
Testing Pipes and Validators
// ── Testing a pure pipe without TestBed ──────────────────────────────────
describe('CdnUrlPipe', () => {
let pipe: CdnUrlPipe;
beforeEach(() => {
// Pure pipe can be instantiated directly — no TestBed needed
// But inject() requires a context, so we use TestBed for inject
TestBed.configureTestingModule({
providers: [CdnUrlPipe,
{ provide: APP_CONFIG, useValue: { apiUrl: '', cdnBaseUrl: 'https://cdn.test.com' } }],
});
pipe = TestBed.inject(CdnUrlPipe);
});
it('should return placeholder for null input', () => {
expect(pipe.transform(null)).toBe('/assets/images/placeholder.webp');
});
it('should transform blob URL to CDN URL', () => {
const blob = 'https://storage.blob.core.windows.net/images/photo.webp';
expect(pipe.transform(blob)).toBe('https://cdn.test.com/images/photo.webp');
});
it('should return non-blob URLs unchanged', () => {
const url = 'https://external.com/image.jpg';
expect(pipe.transform(url)).toBe(url);
});
});
// ── Testing synchronous validators ────────────────────────────────────────
describe('slugFormatValidator', () => {
const validator = slugFormatValidator();
const validCases = ['valid-slug', 'post-1', 'a', 'my-post-123'];
const invalidCases = ['', 'Invalid', 'has spaces', 'has--double', '-starts-hyphen'];
validCases.forEach(slug => {
it(`should return null for valid slug: "${slug}"`, () => {
const control = new FormControl(slug);
expect(validator(control)).toBeNull();
});
});
invalidCases.forEach(slug => {
it(`should return error for invalid slug: "${slug}"`, () => {
const control = new FormControl(slug);
expect(validator(control)).not.toBeNull();
expect(validator(control)).toEqual(jasmine.objectContaining({ slugFormat: true }));
});
});
});
// ── Testing async validators ───────────────────────────────────────────────
describe('uniqueSlugValidator', () => {
let postsApi: jasmine.SpyObj<PostsApiService>;
let validator: AsyncValidatorFn;
beforeEach(() => {
postsApi = jasmine.createSpyObj('PostsApiService', ['slugExists']);
validator = uniqueSlugValidator(postsApi);
});
it('should return null when slug is unique', async () => {
postsApi.slugExists.and.returnValue(of(false));
const control = new FormControl('unique-slug');
const result = await firstValueFrom(validator(control) as Observable<ValidationErrors | null>);
expect(result).toBeNull();
});
it('should return slugTaken error when slug exists', async () => {
postsApi.slugExists.and.returnValue(of(true));
const control = new FormControl('taken-slug');
const result = await firstValueFrom(validator(control) as Observable<ValidationErrors | null>);
expect(result).toEqual({ slugTaken: true });
});
it('should debounce API calls', fakeAsync(() => {
postsApi.slugExists.and.returnValue(of(false));
const control = new FormControl('typing-slug');
// Validator is called multiple times rapidly (simulating typing)
validator(control);
validator(control);
validator(control);
tick(400); // after debounce period
// Should only call API once despite 3 validator invocations
expect(postsApi.slugExists).toHaveBeenCalledTimes(1);
}));
});
forEach pattern with it() inside is Jasmine’s equivalent of xUnit’s [InlineData] — it generates multiple test cases from an array of inputs. Each iteration creates a separate test case with its own entry in the test report. This is much cleaner than writing 10 separate it() blocks for 10 slug variations. Jasmine also supports it.each(table) (Jest syntax) if you switch to Jest — covered in Lesson 5.jasmine.objectContaining({ key: value }) for partial object matching — the equivalent of FluentAssertions’ .Should().Contain() for objects. expect(error).toEqual(jasmine.objectContaining({ slugFormat: true })) passes if the error object has a slugFormat: true property, regardless of other properties. This is useful for validator tests where the error object may contain additional metadata beyond the required error key.fakeAsync because they return Observables that are subscribed to by the form control. When testing with firstValueFrom(validator(control)), use async/await (not fakeAsync) for the most straightforward approach. Reserve fakeAsync for tests that specifically need to control time (debounce, throttle) within the validator. The two patterns don’t mix well — pick one per test.Common Mistakes
Mistake 1 — Using TestBed for pure pipe tests when it’s not needed
❌ Wrong — full TestBed setup for a pure pipe; 10 lines of boilerplate for a function that takes input and returns output.
✅ Correct — if the pipe uses inject(), use TestBed minimally; otherwise instantiate directly: const pipe = new TruncatePipe().
Mistake 2 — Testing only the happy path for validators
❌ Wrong — test only that valid slugs return null; invalid slugs never tested; validator bug undetected.
✅ Correct — test both valid and invalid inputs; test edge cases (empty string, maximum length, special characters).