testing-library / angular-testing-library

🐙 Simple and complete Angular testing utilities that encourage good testing practices
https://testing-library.com/angular
MIT License
694 stars 85 forks source link

Proposal: new way to spy output without `any` assertion #462

Open lacolaco opened 1 week ago

lacolaco commented 1 week ago

Context

I think that there is some pains in spying on the output of a component, which is typified by the fact that you have to use type assertions by any. It is my opinion that the cost to pay for simply subscribing to events emitted by a component and inspecting their values can be lesser.

https://github.com/testing-library/angular-testing-library/blob/main/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts#L51-L66

test('output emits a value', async () => {
  const submitFn = jest.fn();
  await render(SignalInputComponent, {
    componentInputs: {
      greeting: 'Hello',
      name: 'world',
    },
    componentOutputs: {
      submit: { emit: submitFn } as any,  // <==
    },
  });

  await userEvent.click(screen.getByRole('button'));

  expect(submitFn).toHaveBeenCalledWith('world');
});

Goals

Idea

This is a pseudo code and has not been strictly validated for feasibility.

test('output emits a value', async () => {
  const submitFn = jest.fn();
  const { outputs } = await render(SignalInputComponent, {
    componentInputs: {
      greeting: 'Hello',
      name: 'world',
    },
  });
  outputs.get('submit').subscribe(submitFn);

  await userEvent.click(screen.getByRole('button'));

  expect(submitFn).toHaveBeenCalledWith('world');
});

outputs.get(eventName)

The eventName should be same to its name in HTML template, <some-cmp (eventName)="..." />. Angular's ComponentMirror has outputs getter which returns a record of propName and tempalteName of outputs. So I think ALT can map the eventName to corresponding property.

https://github.com/angular/angular/blob/main/packages/core/src/render3/component.ts#L104-L139

// pseudo code to get OutputRef from eventName
function getOutputRef<C, T>(componentRef: ComponentRef<C>, eventName: string): OutputRef<T> {
  const { outputs } = reflectComponentType(componentRef.componentType);
  const output = outputs.find(o => o.propName === eventName);
  if (output == null) {
    throw new Error(`Output ${eventName} doesn't exist.`);
  }
  return componentRef.instance[output.templateName];
}
timdeschryver commented 1 week ago

Thanks for the input @lacolaco ! I agree that the current design works, but can be improved. I was thinking of introducing a new createOutputListener method as a work around.

test('output emits a value', async () => {
  const submitFn = jest.fn();
  const { outputs } = await render(SignalInputComponent, {
    componentInputs: {
      greeting: 'Hello',
      name: 'world',
    },
    componentOutputs: {
      submit: createOutputListener(submitFn)
    }
  });

  await userEvent.click(screen.getByRole('button'));

  expect(submitFn).toHaveBeenCalledWith('world');
});

That being said, I like your approach as well, and I think it will work better with Angular in the future (if something will be changed for Output properties).

Let's think on this for a while - I'll also send a tweet of other opinions (feel free to do this as well).