Search code examples
typescriptamazon-web-servicesjestjspartialsts-jest

How can I use TypeScript Partials to test AWS Lambda?


Very similar to Using partial shape for unit testing with typescript but I'm failing to understand why the Partial type is seen as being incompatible with the full version.

I have a unit test which checks if a lambda returns 400 if the body in an AWS lambda event isn't valid. To avoid creating noise for my colleagues, I don't want to create invalidEvent with all the properties of a full APIGatewayProxyEvent. Hence using a Partial<APIGatewayProxyEvent>.

  it("should return 400 when request event is invalid", async () => {
    const invalidEvent: Partial<APIGatewayProxyEvent> = {
      body: JSON.stringify({ foo: "bar" }),
    };
    const { statusCode } = await handler(invalidEvent);
    expect(statusCode).toBe(400);
  });

The const { statusCode } = await handler(invalidEvent); line fails compilation with:

Argument of type 'Partial<APIGatewayProxyEvent>' is not assignable to parameter of type 'APIGatewayProxyEvent'.
  Types of property 'body' are incompatible.
    Type 'string | null | undefined' is not assignable to type 'string | null'.
      Type 'undefined' is not assignable to type 'string | null'.ts(2345)

I understand APIGatewayProxyEvent body can be string | null (from looking at the types) but where did string | null | undefined come from? Why isn't my body - which is a string - a valid body for APIGatewayProxyEvent

How can I use TypeScript Partials to test AWS Lambda?

I could use as to do type assertions but I find Partials more explicit. The following code works though:

    const invalidEvent = { body: JSON.stringify({ foo: "bar" }) } as APIGatewayProxyEvent;

Update: using Omit and Pick to make a new type

  type TestingEventWithBody = Omit<Partial<APIGatewayProxyEvent>, "body"> & Pick<APIGatewayProxyEvent, "body">;

  it("should return 400 when request event is invalid", async () => {
    const invalidEvent: TestingEventWithBody = { body: JSON.stringify({ foo: "bar" }) };
    const { statusCode } = await handler(invalidEvent);
    expect(statusCode).toBe(400);
  });

Fails with:

Argument of type 'TestingEventWithBody' is not assignable to parameter of type 'APIGatewayProxyEvent'.
  Types of property 'headers' are incompatible.
    Type 'APIGatewayProxyEventHeaders | undefined' is not assignable to type 'APIGatewayProxyEventHeaders'.
      Type 'undefined' is not assignable to type 'APIGatewayProxyEventHeaders'.ts(2345)

Solution

  • I'm failing to understand why the Partial type is seen as being incompatible with the full version

    Fundamentally, that's inevitable - you started with something that required the body property to be string | null, and created something with the weaker requirement string | null | undefined. You did provide the body in this case, but that doesn't matter because handler is only seeing invalidEvent through the Partial<APIGatewayProxyEvent> interface and the compiler knows that property could be missing. As you've seen, if you patch up that one property to be required again it just complains about the next one instead.

    In cases where you don't own the handler API, you only really have three choices, none of which is ideal:

    1. Actually provide a full APIGatewayProxyEvent (see the end for a shortcut to this);
    2. Claim to the compiler that your test object is a full APIGatewayProxyEvent with a type assertion; or
    3. Tell the compiler not to check it at all with a // @ts-ignore comment.

    Using Partial is generally just a step in option 2, using:

    const thing: Partial<Thing> = { ... };
    whatever(thing as Thing);
    

    instead of:

    const thing = { ... } as Thing;
    whatever(thing);
    

    If you own handler's API, the best way to do this is to apply the interface segregation principle and be specific about what it actually needs to do its job. If it's only the body, for example:

    type HandlerEvent = Pick<APIGatewayProxyEvent, "body">;
    
    function handler(event: HandlerEvent) { ... } 
    

    A full APIGatewayProxyEvent is still a valid argument to handler, because that definitely does have a body (and the fact that it also has other properties is irrelevant, they're inaccessible via HandlerEvent). This also acts as built-in documentation as to what you're actually consuming from the full object.

    In your tests, you can now just create the smaller object:

    it("should return 400 when request event is invalid", async () => {
      const invalidEvent: HandlerEvent = { body: JSON.stringify({ foo: "bar" }) };
      const { statusCode } = await handler(invalidEvent);
      expect(statusCode).toBe(400);
    });
    

    As a bonus, if it turns out later on that you need to access more of event's properties inside handler, you can update the type:

    type HandlerEvent = Pick<APIGatewayProxyEvent, "body" | "headers">;
    

    and you'll get errors everywhere you need to update the test data to take account of that. This would not happen with const invalidEvent = { ... } as APIGatewayProxyEvent;, you'd have to track down the changes by seeing which tests failed at runtime.


    Another shortcut I've seen used with option 1 is to wrap a function around the partial, providing sensible defaults:

    function createTestData(overrides: Partial<APIGatewayProxyEvent>): APIGatewayProxyEvent {
      return {
        body: null,
        headers: {},
        // etc.
        ...overrides,
      };
    }
    
    it("should return 400 when request event is invalid", async () => {
      const invalidEvent = createTestData({ body: JSON.stringify({ foo: "bar" }) });
      const { statusCode } = await handler(invalidEvent);
      expect(statusCode).toBe(400);
    });
    

    In this case you should make the defaults as minimal as possible (null, 0, "", empty objects and arrays, ...), to avoid any particular behaviour depending on them.