Add integration testing utilities#679
Conversation
🦋 Changeset detectedLatest commit: d836662 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
db41b2d to
a6a34de
Compare
…nd `setIntegrationConfig` Co-authored-by: Copilot <copilot@github.com>
- Changed the import path for testing integration from '@ima/plugin-testing-integration' to '@ima/testing-library/integration'. - Enhanced the createIMAServer function to accept an explicit environmentName parameter. - Added tests to ensure environment resolution occurs at the factory call rather than module load. - Refactored environmentFactory to resolve environment names more cleanly and added support for explicit environment overrides. - Updated TypeScript definitions to include environmentName in server configuration. - Modified testing library to allow setting a specific environment for jsdom HTML template generation. - Introduced new tests for booting the IMA app and integration app initialization to validate environment propagation and error handling.
| @@ -0,0 +1,84 @@ | |||
| import type { AppEnvironment } from '@ima/core'; | |||
There was a problem hiding this comment.
Cannot we unify the integration configuration under already existing client / server config? We could add integration key if we want to distinguish between unit and integration infrastructure. I am also seeing some overlaps here (appMainPath should be already available via import('app/main'), rootDir already exists in client config, environment already exists in server config).
| onLoad = false, | ||
| }: BootImaAppOptions): Promise<ImaApp> { | ||
| if (environment !== undefined) { | ||
| setImaEnvironment(environment); |
There was a problem hiding this comment.
Does this do anything?
The environment is configured already when generating the JSDOM HTML template and should be already set to correct value. This override probably just sets the same value again, doesn't it?
| const appMainPath = path.isAbsolute(config.appMainPath) | ||
| ? config.appMainPath | ||
| : path.resolve(config.rootDir, config.appMainPath); | ||
| const mainModule = await import(appMainPath); | ||
| const getInitialAppConfigFunctions = | ||
| mainModule.getInitialAppConfigFunctions || | ||
| mainModule.default?.getInitialAppConfigFunctions; | ||
|
|
||
| if (!getInitialAppConfigFunctions) { | ||
| throw new Error( | ||
| `Cannot find getInitialAppConfigFunctions in ${config.appMainPath}. ` + | ||
| `Make sure the file exports getInitialAppConfigFunctions function.` | ||
| ); | ||
| } | ||
|
|
||
| // Prefer the project's @ima/core export to ensure we use the same pluginLoader | ||
| // singleton. This is critical when the package is npm-linked, as the link would | ||
| // otherwise resolve to a separate @ima/core instance. | ||
| const ima = mainModule.ima || mainModule.default?.ima || imaFallback; | ||
|
|
||
| defaultBootConfigMethods = | ||
| typeof getInitialAppConfigFunctions === 'function' | ||
| ? ((await getInitialAppConfigFunctions()) as BootConfigMethods) | ||
| : (getInitialAppConfigFunctions as BootConfigMethods); | ||
|
|
||
| app = await bootImaApp({ | ||
| ima, | ||
| appConfigFunctions: { | ||
| initSettings: _mergeBootConfigMethod('initSettings'), | ||
| initBindApp: _mergeBootConfigMethod('initBindApp'), | ||
| initServicesApp: _mergeBootConfigMethod('initServicesApp'), | ||
| initRoutes: _mergeBootConfigMethod('initRoutes'), | ||
| }, | ||
| environment: config.environment, | ||
| onLoad: true, | ||
| }); |
There was a problem hiding this comment.
I feel like this could be simplified / unified a bit more with initImaApp from rtl.ts.
const mainModule = await import(appMainPath); could be replaced with top-level import { ima, getInitialAppConfigFunctions } from 'app/main';. And maybe we could also move ima definition inside of bootImaApp?
In the end, we could have just something like this in here.
import { getInitialAppConfigFunctions } from 'app/main';
// ...
if (!getInitialAppConfigFunctions) {
throw new Error(
`Cannot find getInitialAppConfigFunctions in ${config.appMainPath}. ` +
`Make sure the file exports getInitialAppConfigFunctions function.`
);
}
app = await bootImaApp({
appConfigFunctions: {
initSettings: _mergeBootConfigMethod('initSettings'),
initBindApp: _mergeBootConfigMethod('initBindApp'),
initServicesApp: _mergeBootConfigMethod('initServicesApp'),
initRoutes: _mergeBootConfigMethod('initRoutes'),
},
onLoad: true,
});Maybe it could be pushed even further and we could make _mergeBootConfigMethod a bit more generic and merge the default appConfigFunctions (from app/main) with the ones we send in bootImaApp inside bootImaApp. But we also need to merge configuration from args and integration config in this file and it could be a bit messy and confusing.
| environment, | ||
| processEnvironment, | ||
| }) { | ||
| const env = resolveEnvironmentName(environment); |
There was a problem hiding this comment.
Changeset file is missing for @ima/server.
There was a problem hiding this comment.
I am actually a bit worried about this change. It is fixing a bug, but it could be breaking for some use-cases. We are changing the moment of environment evaluation. This will fix @ima/storybook-integration to actually use regression environment when running storybook as opposed to value from NODE_ENV env variable.
Can we test this change in @ima/server to see if it affects our services especially in storybook visual regression tests?
| }); | ||
|
|
||
| // To use IMA route handler in jsdom | ||
| (app.oc.get('$Router') as any).listen(); |
There was a problem hiding this comment.
I think this should be typed as ClientRouter from @ima/core
| */ | ||
| export async function initImaApp( | ||
| bootConfigMethods: BootConfigMethods = {} | ||
| ): Promise<ImaApp & Record<string, unknown>> { |
There was a problem hiding this comment.
Maybe we should introduce an internal type ImaAppExtended = ImaApp & Record<string, unknown>, since we repeat this construction in multiple places.
| const config = getIntegrationConfig(); | ||
| const { TestPageRenderer } = config; | ||
| const pageRendererResults: unknown[] = []; | ||
| let app: (ImaApp & Record<string, unknown>) | null = null; |
There was a problem hiding this comment.
Shouldn't this be typed only as ImaApp | null?
There was a problem hiding this comment.
Is there even a reason for this to be pre-defined here and filled with value later? Cannot we just define the variable later?
| validateJsdomEnvironment(); | ||
|
|
||
| const config = getIntegrationConfig(); | ||
| const { TestPageRenderer } = config; |
There was a problem hiding this comment.
Maybe we should remove support for TestPageRenderer configuration? The original idea was to hypothetically implement custom renderers as an alternative to @ima/react-page-renderer. For example using Enzyme, or React Testing Library. But we were never able to really use it anywhere. Since we removed the only available test page renderer (Enzyme) with this migration, maybe we should drop support for this functionality completely?
If somebody still needs this, users can actually extend initBindApp boot config method to override the default page renderer. We don't really need this custom config to accomplish this. It waas supposed to be just a convinience API, but it never made it to a real world scenario.
| * This simulates browser behavior where the URL is already set in the address bar. | ||
| */ | ||
| export function initBindApp(ns: Namespace, oc: ObjectContainer): void { | ||
| const Router = (oc.get('$Router') as any).constructor; |
There was a problem hiding this comment.
The type here could also be ClientRouter
| hookName.beforeMethod, | ||
| 'route', | ||
| ({ args, context }: { args: any[]; context: any }) => { | ||
| const pageManager = oc.get('$PageManager') as any; |
There was a problem hiding this comment.
Also a real type could be used here
| const pageRendererResults: unknown[] = []; | ||
| let app: (ImaApp & Record<string, unknown>) | null = null; | ||
| let result: (ImaApp & Record<string, unknown>) | null = null; | ||
| let defaultBootConfigMethods: BootConfigMethods = {}; |
There was a problem hiding this comment.
Same as app variable, do we really need to pre-define the variable here on top? Cannot we just declare it when its being filled with a value?
Add integration testing utilities with
initImaApp,clearImaApp, andsetIntegrationConfigWhat
./integrationexport from@ima/testing-librarywithinitImaApp,clearImaApp,setIntegrationConfig,getIntegrationConfig,aop,unAopAll,hookName, andcreateHook./jest-preset-integration/jest-presetexport — a Jest preset for integration tests that omits theapp/mainmodule name mapper (the integrationinitImaApploads the real app dynamically from a configurableappMainPathinstead)bootImaAppandvalidateJsdomEnvironmenthelpers into a newboot.tsmodule and re-used them in the existing RTLinitImaAppto-aopWhy
Integration tests need to boot the real IMA application (loaded dynamically from a configurable path) instead of a mocked
app/main, run with a live router, wrap global timers for cleanup, and support per-suite boot config overrides — capabilities not covered by the existing unit-testinitImaApp.How it works
setIntegrationConfig/getIntegrationConfighold configuration:appMainPath,rootDir,environment,prebootScript,TestPageRenderer, and boot config method overridesinitImaAppdynamically imports the app'smain.js, wraps global timers (so they can be cleared after each test), calls$Router.listen()so IMA's route handler is active in jsdom, and merges boot config methods from both the default app functions and the per-suite overridesclearImaApprestores native timers, clears the object container, and callsunAopAlljest-preset-integrationgenerates the jsdom HTML template the same way asjest-preset(viagetIMAResponseContent) but without theapp/mainmoduleNameMapper