angulartypescript

Inferring the generic types of an Angular test utility function


The function I created takes an optional array of objects with a name and value property. I would like to have the value property infer or pass the type of what the value is. I can get it to work properly with one object, but when there is more than one it keeps the type of the first object only. Here is the utility function:

import { Type } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'

export type InputSignal = Record<string, any>

export type AdditionalProvider<K, V> = { name: K; value: Type<V> }

export type SetupTestOptions<K extends string, V> = {
  setInput?: InputSignal
  additionalProviders?: AdditionalProvider<K, V>[]
}

export function setupTest<T extends object, K extends string, V>(
  Component: Type<T>,
  options: SetupTestOptions<K, V> = {}
) {
  const fixture: ComponentFixture<T> = TestBed.createComponent(Component)
  const component: T = fixture.componentInstance

  if (options.setInput) {
    Object.keys(options.setInput).forEach(key => {
      if (!options.setInput) return
      fixture.componentRef.setInput(key, options.setInput[key])
    })
  }

  const providers = <Record<K, V>>{}

  if (options.additionalProviders) {
    options.additionalProviders.forEach(({ name, value }) => {
      providers[name] = TestBed.inject(value) <-- here is where I would like the types to be inferred.
    })
  }

  fixture.detectChanges()

  return { fixture, component, ...providers }
}

Here is an example of how it is used:

it('should route to the dashboard/home route if projectId is null', async () => {
    const { fixture, component, location, ngZone, router } = setupTest(DashboardComponent, {
      additionalProviders: [
        { name: 'location', value: Location },
        { name: 'router', value: Router },
        { name: 'ngZone', value: NgZone }
      ]
    })

    ngZone.run(() => {
      router.initialNavigation()
      component.ngOnInit()
    })

    fixture.whenStable().then(() => {
      expect(location.path()).toBe('/dashboard/home')
    })
  })

I tried many variations of having type V extend different Angular utility types and even explicitly added the types that I want in this use case. The closest I can get is to have a union of the 3 different services (Location | Router | NgZone) but that defeats the purpose because then I have to cast all of the types when using them. I would like TypeScript to infer the correct type based on the value and pass that type to the name I am destructuring in the example.


Solution

  • I agree with Naren, but I also think the type inference makes tests easier to write and maintain, so here's a solution:

    import { Type } from '@angular/core'
    import { ComponentFixture, TestBed } from '@angular/core/testing'
    
    export type InputSignal = Record<string, any>
    
    export type AdditionalProviders = { name: string, value: Type<any> }[];
    
    type GetType<T> = T extends { value: Type<infer V> } ? V : never;
    
    type ProvidersReturnType<TP extends AdditionalProviders> = {
        [TK in TP[number]['name']]: GetType<Extract<TP[number], { name: TK }>>;
    }
    
    export type SetupTestOptions<TProviders extends AdditionalProviders> = {
        setInput?: InputSignal;
        additionalProviders?: TProviders;
    }
    
    export function setupTest<T extends object, const TP extends AdditionalProviders>(
        Component: Type<T>,
        options: SetupTestOptions<TP> = {}
    ) {
        const fixture: ComponentFixture<T> = TestBed.createComponent(Component)
        const component: T = fixture.componentInstance
    
        if (options.setInput) {
            Object.keys(options.setInput).forEach(key => {
                if (!options.setInput) return
                fixture.componentRef.setInput(key, options.setInput[key])
            })
        }
    
        const providers: ProvidersReturnType<TP> = {} as any;
    
        if (options.additionalProviders) {
            options.additionalProviders.forEach(({ name, value }) => {
                (providers as any)[name] = TestBed.inject(value); // < --here is where I would like the types to be inferred.
            });
        }
    
        fixture.detectChanges()
        return { fixture, component, ...providers }
    }
    

    Typescript Playground

    enter image description here