Home > Blockchain >  How can I use TypeScript Partials to test AWS Lambda?
How can I use TypeScript Partials to test AWS Lambda?

Time:10-30

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 check 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)

CodePudding user response:

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.

CodePudding user response:

The problem here is the partial type converts all object properties to optional:

type MyObj = { myKey: string | null };

type MyPartialObj = Partial<MyObj>;
// MyPartialObj is: { myKey?: string | null | undefined }

In the type MyObj the myKey type was string | null. When we converted it to MyPartialObj the myKey type became optional and thus has the potential to be undefined. So now its type is string | null | undefined

Your APIGatewayProxyEvent type expects body to be string | null, however since you've made it partial you are saying body could also be undefined. Yes you have defined it, but you never did a type narrowing to validate that it is indeed a string. So all TypeScript has to go off of is the type you assigned, which again is Partial.


UPDATE: Extending on what @jonrsharpe has said in the comments. My previous solution seems to not work ether, it just pushed the error on to the next property in APIGatewayProxyEvent. See their answer. The issue is that you're trying to mock a part of the data and the type expects all data to be present. It might just be easiest to make an object that has the minimal values for each property instead of trying to fake it. You could use as but that defeats the whole point of using the type system in the first place.


Previous answer: A solution might be to make all values optional except body:

const invalidEvent: Omit<Partial<APIGatewayProxyEvent>, 'body'> & Pick<APIGatewayProxyEvent, 'body'> = {
    body: JSON.stringify({ foo: "bar" }),
};

Also, avoid using as x like the plague or unless you're absolutely sure what you're doing.

  • Related