aws / aws-cdk

The AWS Cloud Development Kit is a framework for defining cloud infrastructure in code
https://aws.amazon.com/cdk
Apache License 2.0
11.61k stars 3.91k forks source link

aws-cdk-lib: jest.resetModules causes tests to fail starting with 2.94.0 #27312

Open trobert2 opened 1 year ago

trobert2 commented 1 year ago

Describe the bug

When upgrading from 2.88.0 to 2.98.0 we get this error when running the same code:

TypeError: Class extends value undefined is not a constructor or null

No code changes have been introduced. just package updates.

diff --git a/package-lock.json b/package-lock.json
index fe055dc..9ce311f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,9 @@
       "name": "@npm-shared-services/cdk-common-components",
       "version": "0.54.0",
       "dependencies": {
-        "@aws-cdk/aws-apigatewayv2-alpha": "2.88.0-alpha.0",
-        "@aws-cdk/aws-apigatewayv2-authorizers-alpha": "2.88.0-alpha.0",
-        "@aws-cdk/aws-apigatewayv2-integrations-alpha": "2.88.0-alpha.0",
+        "@aws-cdk/aws-apigatewayv2-alpha": "2.98.0-alpha.0",
+        "@aws-cdk/aws-apigatewayv2-authorizers-alpha": "2.98.0-alpha.0",
+        "@aws-cdk/aws-apigatewayv2-integrations-alpha": "2.98.0-alpha.0",
         "@aws-lambda-powertools/logger": "1.13.1",
         "aws-lambda": "1.0.7",
         "source-map-support": "^0.5.21"
@@ -21,8 +21,8 @@
         "@types/aws-lambda": "8.10.122",
         "@types/jest": "^29.5.5",
         "@types/node": "20.7.0",
-        "aws-cdk": "2.88.0",
-        "aws-cdk-lib": "2.88.0",
+        "aws-cdk": "2.98.0",
+        "aws-cdk-lib": "2.98.0",
         "constructs": "^10.2.70",
         "cross-env": "7.0.3",
         "eslint": "8.50.0",

Expected Behavior

tests pass, deployment works

Current Behavior

(node:75173) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
 FAIL  test/base-dns.test.ts
  ● testing stack › Domains stack

    TypeError: Class extends value undefined is not a constructor or null

      43 |     )
      44 |
    > 45 |     new CrossAccountZoneDelegationRecord(this, 'delegatecSubdomain', {
         |     ^
      46 |       delegatedZone: GuideZone,
      47 |       parentHostedZoneName: config.main_hosted_zone_name,
      48 |       delegationRole

      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/custom-resource-provider/cross-region-export-providers/export-writer-provider.js:1:717)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/private/refs.js:1:561)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/private/prepare-app.js:1:245)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/private/synthesis.js:1:333)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/app.js:1:425)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/annotations.js:1:260)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/stack.js:1:450)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/names.js:1:497)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/asset-staging.js:1:551)
      at Object.<anonymous> (node_modules/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.js:1:532)
      at aws_cdk_lib_CustomResourceProviderRuntime (node_modules/aws-cdk-lib/.warnings.jsii.js:2:344450)
      at Object.aws_cdk_lib_CustomResourceProviderProps (node_modules/aws-cdk-lib/.warnings.jsii.js:2:344155)
      at Function.getOrCreateProvider (node_modules/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.js:1:1966)
      at new CrossAccountZoneDelegationRecord (node_modules/aws-cdk-lib/aws-route53/lib/record-set.js:1:11869)
      at new DNSStack (lib/stacks/base-dns-stack.ts:45:5)
      at Object.<anonymous> (test/base-dns.test.ts:37:19)

(node:75174) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

Reproduction Steps

The code is redacted for obvious purposes. The code works fine with 2.88 and no changes have been added to the code or the context values (see diff above, that's all that changed)

Write a stack that include this:

...
    const delegationRoleArn = Stack.of(this).formatArn({
      region: '', // IAM is global in each partition
      service: 'iam',
      account: SHARED_SERVICES_ACCOUNT,
      resource: 'role',
      resourceName: CROSS_ACCOUNT_DELEGATION_ROLE_NAME
    })

    const delegationRole = Role.fromRoleArn(
      this,
      'DelegationRole',
      delegationRoleArn
    )

    // NEW ROAMY DELEGATION
    const roamyGuideZone = new PublicHostedZone(
      this,
      'GuideSubdomainHostedZone',
      {
        zoneName: config.subdomain_hosted_zone_name
      }
    )

    new CrossAccountZoneDelegationRecord(this, 'delegateSubdomain', {
      delegatedZone: GuideZone,
      parentHostedZoneName: config.main_hosted_zone_name,
      delegationRole
    })
...

Write a test for it

import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

jest.useFakeTimers()

describe('testing stack', () => {
  const OLD_ENV_STAGE = process.env.STAGE
  const testingStage = 'testing'

  beforeEach(() => {
    jest.resetModules()
    process.env.STAGE = testingStage
  })

  afterAll(() => {
    process.env.STAGE = OLD_ENV_STAGE
  })

  test('Domains stack', () => {
    const app = new App({
      context: {
        testing: {
          main_hosted_zone_name: 'xxxxxxxx.guide',
          subdomain_hosted_zone_name: 'staging.xxxxxxxx.guide',
          sharing_subdomain_hosted_zone_name:
            'staging.xxxxxxxx.guide',
          sharing_subdomain_entry: 'sharing.staging.xxxxxxxx.guide'
        }
      }
    })
    // WHEN
    const envEU = { account: '000000000000', region: 'eu-central-1' }

    const stack = new DNSStack(app, 'MyTestDNSStack', {
      env: envEU,
      crossRegionReferences: true
    })
    // THEN
    expectCDK(stack).to(countResources('AWS::Route53::HostedZone', 1))
  })
})

Run test:

STAGE=testing NODE_OPTIONS=--experimental-vm-modules npx jest

Possible Solution

No response

Additional Information/Context

No response

CDK CLI Version

2.98.0 (build b04f852)

Framework Version

2.98.0

Node.js Version

v20.2.0

OS

mac arm

Language

Typescript

Language Version

5.2.2

Other information

No response

The-Zona-Zoo commented 1 year ago

+1 also seeing this when doing new VPC(...). Version 2.94.0 appears to be what broke it as 2.93.0 works fine.

peterwoodworth commented 1 year ago

You're right, thanks for the reproduction steps. I've been able to reproduce this and see that it is breaking going from 2.93.0 to 2.94.0.

I noticed that if you remove the jest.resetModules() line, then the test succeeds.

May be related to this change https://github.com/aws/aws-cdk/pull/26854

rix0rrr commented 1 year ago

I see that there was a Jest-related change in 2.94.0 which @peterwoodworth found, but I'm 99% confident that should only affect the CDK repository itself, not any downstream users of the aws-cdk-lib library.

This is mystery. I'm looking through the set of changes and I can't even find anything else that looks suspicious 🤔

rix0rrr commented 1 year ago

To be honest I'm unsure about the purpose of resetModules inside a beforeEach() statement.

As per the documentation, it's going to affect all subsequent require() calls. In the example code you showed, some imports (which turn into require()s) have already been done at the top of the file, and then subsequent require()s that will be executed in the middle of a test will return a fresh in-memory copy of the module:

// These run at module load time, will not be affected by 'resetModules()'
import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

jest.useFakeTimers()

describe('testing stack', () => {
  beforeEach(() => {
    jest.resetModules()
    // Every require() or import performed AFTER this point will get a new copy of the module
  })
});

What might work is doing the following:

import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

let expectCDK: typeof import('@aws-cdk/assert')['expect'];
let countResources: typeof import('@aws-cdk/assert')['countResources'];
let App: typeof import('aws-cdk-lib)['App'];
let DNSStack: typeof import('../lib/stacks')['DNSStack'];

beforeEach(() => {
  jest.resetModules();
  expectCDK = require('@aws-cdk/assert').expect;
  countResources = require('@aws-cdk/assert').countResources;
  App = require('aws-cdk-lib').App;
  DNSStack = require('../lib/stacks').DNSStack;
});

To make sure that the require() happens after resetModules is called, and symbols from different module copies aren't intermixed (they would all be from the same "load generation", if that makes sense).

rix0rrr commented 1 year ago

By the way, the code that is failing looks to be:

const constructs_1=require("constructs");

class ExportWriter extends constructs_1.Construct
                                          ^^^^ value undefined is not a constructor or null

So it looks like constructs.Construct is undefined. Still don't understand how that would happen.

trobert2 commented 1 year ago

To be honest I'm unsure about the purpose of resetModules inside a beforeEach() statement.

As per the documentation, it's going to affect all subsequent require() calls. In the example code you showed, some imports (which turn into require()s) have already been done at the top of the file, and then subsequent require()s that will be executed in the middle of a test will return a fresh in-memory copy of the module:

// These run at module load time, will not be affected by 'resetModules()'
import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

jest.useFakeTimers()

describe('testing stack', () => {
  beforeEach(() => {
    jest.resetModules()
    // Every require() or import performed AFTER this point will get a new copy of the module
  })
});

What might work is doing the following:

import { expect as expectCDK, countResources } from '@aws-cdk/assert'
import { jest } from '@jest/globals'
import { App } from 'aws-cdk-lib'

import { DNSStack } from '../lib/stacks'

let expectCDK: typeof import('@aws-cdk/assert')['expect'];
let countResources: typeof import('@aws-cdk/assert')['countResources'];
let App: typeof import('aws-cdk-lib)['App'];
let DNSStack: typeof import('../lib/stacks')['DNSStack'];

beforeEach(() => {
  jest.resetModules();
  expectCDK = require('@aws-cdk/assert').expect;
  countResources = require('@aws-cdk/assert').countResources;
  App = require('aws-cdk-lib').App;
  DNSStack = require('../lib/stacks').DNSStack;
});

To make sure that the require() happens after resetModules is called, and symbols from different module copies aren't intermixed (they would all be from the same "load generation", if that makes sense).

Thanks for the suggestion. I don't think that would work with ESM modules. It's worth mentioning that my package.json contains "type": "module",

So I think this results in:

    ReferenceError: require is not defined

I also think your import statement needs changing because:

Import declaration conflicts with local declaration of 'expectCDK'.ts(2440) so you can't have:

import { expect as expectCDK, countResources } from '@aws-cdk/assert'

and

let expectCDK: typeof import('@aws-cdk/assert')['expect']

Furthermore, in order for this to work, in case you are using eslint, you would need to add:

    // eslint-disable-next-line @typescript-eslint/no-var-requires
trobert2 commented 1 year ago

@peterwoodworth, for this particular case removing resetModules indeed fixed my issue. These tests pass. I would like to add that this solution doesn't apply to some of my other modules where I care about the context more. I would appreciate if this is still treated as a bug. What do you think?

rix0rrr commented 1 year ago

Thanks for the suggestion. I don't think that would work with ESM modules. It's worth mentioning that my package.json contains "type": "module", So I think this results in: ReferenceError: require is not defined

In that case, I think you would write:

beforeEach(async () => {
  jest.resetModules();
  expectCDK = (await import('@aws-cdk/assert')).expect;
  countResources = (await import('@aws-cdk/assert')).countResources;
  App = (await import('aws-cdk-lib')).App;
  DNSStack = (await import('../lib/stacks')).DNSStack;
});

I also think your import statement needs changing because:

My mistake, I meant to remove the top-level import but apparently forgot to do so. Here it is again for ESM syntax:

import { jest } from '@jest/globals'

let expectCDK: typeof import('@aws-cdk/assert')['expect'];
let countResources: typeof import('@aws-cdk/assert')['countResources'];
let App: typeof import('aws-cdk-lib)['App'];
let DNSStack: typeof import('../lib/stacks')['DNSStack'];

beforeEach(async () => {
  jest.resetModules();
  expectCDK = (await import('@aws-cdk/assert')).expect;
  countResources = (await import('@aws-cdk/assert')).countResources;
  App = (await import('aws-cdk-lib')).App;
  DNSStack = (await import('../lib/stacks')).DNSStack;
});

I would appreciate if this is still treated as a bug. What do you think?

I'm happy to keep this open as a bug for people to collect insights on. I have no idea what would be causing this issue though, and no idea of how to start tracking it.

It would help if you could come up with a minimal reproducing example.

The-Zona-Zoo commented 1 year ago
beforeEach(async () => {
  jest.resetModules();
  expectCDK = (await import('@aws-cdk/assert')).expect;
  countResources = (await import('@aws-cdk/assert')).countResources;
  App = (await import('aws-cdk-lib')).App;
  DNSStack = (await import('../lib/stacks')).DNSStack;
});

Confirming this work around does work.

To be honest I'm unsure about the purpose of resetModules inside a beforeEach() statement.

For some more insight, the reason we use resetModules is to test logic that uses environment variables. Obviously there are other ways to go about this but modifying the environment and then using resetModules was the easiest way based on the code.

rix0rrr commented 1 year ago

@The-Zona-Zoo, thanks, that's helpful. Are you referring to CDK code that is caching those environment variables into global variables somewhere?

Again, a reproducing example would be helpful.

The-Zona-Zoo commented 1 year ago

@rix0rrr While I can't post the full example, it's essentially something to the effect of:

let App: (typeof import('aws-cdk-lib/core'))['App'];
let Template: (typeof import('aws-cdk-lib/assertions'))['Template'];

describe('SomeCdkStack', () => {
  const previousEnv = { ...process.env };
  beforeEach(async () => {
    if (process.env.USER) {
      delete process.env.USER;
      jest.resetModules();
    }
    App = (await import('aws-cdk-lib/core')).App;
    Template = (await import('aws-cdk-lib/assertions')).Template;
  });
  afterEach(() => {
    process.env = previousEnv;
  });
});

This example doesn't work with importing App and Template directly (as of 2.94.0).

The stack being tested in question has a statement similar to env.USER ?? 'someDefault' or env.USER ? 'userDefault' : 'nonUserDefault'. We use this mechanism to allow developers to have their own AWS resources with their username's appended while defaulting for the case of say beta and production environments.