feature: add tests for unit and other tests packages as well#15
feature: add tests for unit and other tests packages as well#15Anmol1696 wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds comprehensive test infrastructure for the constructive-functions repository. It introduces Jest as the testing framework with TypeScript support via ts-jest, adds unit tests for all three function handlers (example, simple-email, send-email-link), creates integration tests for the runtime HTTP layer, implements mock helpers and module mocks, and sets up a CI workflow to run tests automatically.
Changes:
- Added Jest testing framework with TypeScript support (ts-jest) and comprehensive test configuration
- Implemented unit tests for all function handlers (example, simple-email, send-email-link) with validation and behavior coverage
- Created integration tests for the fn-runtime HTTP layer with helper utilities for function server management
- Added mock implementations for external dependencies (postmaster, SMTP, logger, env utilities, MJML, GraphQL tag)
- Set up GitHub Actions CI workflow with separate unit and integration test jobs
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| jest.config.ts | Jest configuration with ts-jest preset, test patterns, module mocks, and 30s timeout |
| package.json | Added test scripts (test, test:unit, test:integration) and Jest/ts-jest dependencies |
| .github/workflows/test.yaml | CI workflow with separate unit and integration test jobs running on Node 22 |
| tests/helpers/mock-context.ts | Mock context factory for unit testing function handlers with configurable options |
| tests/mocks/*.ts | Mock implementations for external dependencies (7 mocks total) |
| tests/integration/runtime.test.ts | Integration tests for fn-runtime HTTP layer testing POST requests and error handling |
| tests/integration/helpers/start-function.ts | Helper utility to start function servers for integration testing |
| functions/example/tests/handler.test.ts | Unit tests for example handler covering response shape and error throwing |
| functions/simple-email/tests/handler.test.ts | Comprehensive unit tests for simple-email covering validation, sending, env config, and dry-run mode |
| functions/send-email-link/tests/handler.test.ts | Unit tests for send-email-link handler focusing on validation logic |
| functions/send-email-link/tests/handler.graphql.test.ts | TODO placeholders for future GraphQL integration tests |
| pnpm-lock.yaml | Added ~200+ Jest ecosystem packages and dependencies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const EXAMPLE_MODULE = path.resolve( | ||
| __dirname, | ||
| '../../generated/example/dist/index.js' | ||
| ); |
There was a problem hiding this comment.
The integration test references generated/example/dist/index.js which requires building the example function first. However, the test:integration script only runs jest --testPathPatterns='tests/integration' without ensuring the functions are built.
The test workflow does include pnpm build before running pnpm test:integration, but this dependency should also be documented in the test script or the integration test file itself to prevent local test failures.
Consider either:
- Adding a comment in the integration test file noting the build requirement
- Creating a pretest:integration script that runs the build
- Adding a check in the integration test to fail early with a helpful message if the file doesn't exist
| }, | ||
| body: JSON.stringify({}) | ||
| }); | ||
| expect(res.status).toBe(200); |
There was a problem hiding this comment.
The test "passes job headers to handler context" doesn't verify that the headers are actually passed correctly - it only checks that the response is 200. The test should verify that the context received the expected job headers (X-Job-Id, X-Worker-Id, X-Database-Id) by checking the handler's behavior or response.
Consider either mocking the handler to verify it received the correct context, or having the example handler echo back the context so you can verify the headers were properly parsed.
| expect(res.status).toBe(200); | |
| expect(res.status).toBe(200); | |
| const body = await res.json(); | |
| expect(body).toHaveProperty('context'); | |
| expect(body.context).toMatchObject({ | |
| jobId: 'job-123', | |
| workerId: 'worker-456', | |
| databaseId: 'db-789' | |
| }); |
| // Inline type to avoid requiring fn-runtime to be built for unit tests. | ||
| // Mirrors FunctionContext from packages/fn-runtime/src/types.ts | ||
| type FunctionContext = { | ||
| job: { | ||
| jobId?: string; | ||
| workerId?: string; | ||
| databaseId?: string; | ||
| }; | ||
| client: { request: (...args: any[]) => Promise<any> }; | ||
| meta: { request: (...args: any[]) => Promise<any> }; | ||
| log: { | ||
| info: (...args: any[]) => void; | ||
| error: (...args: any[]) => void; | ||
| warn: (...args: any[]) => void; | ||
| }; | ||
| env: Record<string, string | undefined>; | ||
| }; |
There was a problem hiding this comment.
The FunctionContext type is duplicated here as a comment says it mirrors the type from packages/fn-runtime/src/types.ts. This creates a maintenance risk - if the real type changes, tests could pass with an outdated mock type.
Consider either:
- Importing the actual type from @constructive-io/fn-runtime (preferred)
- Using a build step to generate this type
- Adding a test that validates the mock type matches the real type structure
|
|
||
| describe('send-email-link handler (validation)', () => { | ||
| let handler: any; |
There was a problem hiding this comment.
The test uses 'any' type for the handler variable, which disables TypeScript's type checking. This could hide potential type mismatches. Consider using the proper type:
let handler: FunctionHandler<any>;or importing the specific handler type from the module.
| describe('send-email-link handler (validation)', () => { | |
| let handler: any; | |
| import { FunctionHandler } from '@azure/functions'; | |
| describe('send-email-link handler (validation)', () => { | |
| let handler: FunctionHandler<any>; |
| __dirname, | ||
| '../../generated/example/dist/index.js' | ||
| ); | ||
| const PORT = 19876; |
There was a problem hiding this comment.
Hardcoded port 19876 could cause conflicts if multiple test suites run in parallel or if the port is already in use. Consider using a dynamic port assignment (port 0) and retrieving the actual assigned port from the server, or use a port range with retry logic.
Example approach:
const server = app.listen(0, () => { // 0 = dynamic port
const actualPort = (server.address() as any).port;
resolve({
server,
url: `http://localhost:${actualPort}`,
close: ...
});
});| const PORT = 19876; | |
| const PORT = 0; |
| client: { | ||
| request: jest.fn().mockResolvedValue(options.clientResponse ?? {}) | ||
| } as any, | ||
| meta: { | ||
| request: jest.fn().mockResolvedValue(options.metaResponse ?? {}) |
There was a problem hiding this comment.
The mock context uses 'any' type for the client and meta request functions. This reduces type safety and could hide bugs. Consider defining a proper mock type or using a more specific type like:
client: {
request: jest.fn<Promise<any>, [string, Record<string, any>?]>().mockResolvedValue(options.clientResponse ?? {})
} as any,Or better yet, import and use the actual GraphQLClient type from graphql-request.
| it('returns error when databaseId is missing', async () => { | ||
| const ctx = createMockContext({ databaseId: undefined }); | ||
| const result = await handler( | ||
| { email_type: 'invite_email', email: 'a@b.com' }, | ||
| ctx | ||
| ); | ||
| expect(result).toEqual({ | ||
| error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' | ||
| }); | ||
| }); | ||
|
|
||
| it('throws for missing email_type', async () => { | ||
| await expect( | ||
| handler({ email: 'a@b.com' }, createMockContext()) | ||
| ).rejects.toThrow('Missing required field: email_type'); | ||
| }); | ||
|
|
||
| it('throws for missing email', async () => { | ||
| await expect( | ||
| handler({ email_type: 'invite_email' }, createMockContext()) | ||
| ).rejects.toThrow('Missing required field: email'); | ||
| }); | ||
|
|
||
| it('throws for invite_email missing invite_token/sender_id', async () => { | ||
| await expect( | ||
| handler( | ||
| { email_type: 'invite_email', email: 'a@b.com' }, | ||
| createMockContext() | ||
| ) | ||
| ).rejects.toThrow('Missing required field: invite_token_or_sender_id'); | ||
| }); | ||
|
|
||
| it('throws for forgot_password missing user_id/reset_token', async () => { | ||
| await expect( | ||
| handler( | ||
| { email_type: 'forgot_password', email: 'a@b.com' }, | ||
| createMockContext() | ||
| ) | ||
| ).rejects.toThrow('Missing required field: user_id_or_reset_token'); | ||
| }); | ||
|
|
||
| it('throws for email_verification missing email_id/verification_token', async () => { | ||
| await expect( | ||
| handler( | ||
| { email_type: 'email_verification', email: 'a@b.com' }, | ||
| createMockContext() | ||
| ) | ||
| ).rejects.toThrow( | ||
| 'Missing required field: email_id_or_verification_token' | ||
| ); | ||
| }); |
There was a problem hiding this comment.
The test pattern for validation in send-email-link differs from simple-email. In send-email-link, some validations return an error object { error: '...' } while others throw errors. This inconsistency could be confusing.
Looking at the handler code (lines 271-298 in handler.ts), the handler wraps validation failures that return { missing: '...' } into thrown errors. The test on line 11-20 correctly tests the early-return error case for missing databaseId, but the remaining tests check for thrown errors after validation failures.
Consider documenting this dual error handling pattern or normalizing to a single approach for consistency.
| "lint": "pnpm -r --if-present run lint" | ||
| "lint": "pnpm -r --if-present run lint", | ||
| "test": "jest", | ||
| "test:unit": "jest --testPathPatterns='functions/.+/__tests__'", |
There was a problem hiding this comment.
The test script uses --testPathPatterns with a pattern that may not match correctly. The pattern 'functions/.+/__tests__' will match any path containing functions/ followed by any characters and then /__tests__, but test files are typically in __tests__/*.test.ts.
Consider using a more explicit pattern:
"test:unit": "jest --testPathPattern='functions/.*/__tests__/.*\\.test\\.ts$'"Or rely on Jest's default test matching and use --testMatch instead:
"test:unit": "jest functions"| "test:unit": "jest --testPathPatterns='functions/.+/__tests__'", | |
| "test:unit": "jest functions", |
| ] | ||
| }, | ||
| transformIgnorePatterns: ['/node_modules/'], | ||
| testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', |
There was a problem hiding this comment.
The testRegex pattern (/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$ is complex and might not match all intended test files. According to Jest best practices, it's generally better to use testMatch instead of testRegex for clarity.
Consider replacing with:
testMatch: [
'**/__tests__/**/*.test.ts',
'**/__tests__/**/*.spec.ts',
'**/*.test.ts',
'**/*.spec.ts'
],This is more explicit and easier to understand.
| testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', | |
| testMatch: [ | |
| '**/__tests__/**/*.test.ts', | |
| '**/__tests__/**/*.test.tsx', | |
| '**/__tests__/**/*.spec.ts', | |
| '**/__tests__/**/*.spec.tsx', | |
| '**/*.test.ts', | |
| '**/*.test.tsx', | |
| '**/*.spec.ts', | |
| '**/*.spec.tsx' | |
| ], |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export const parseEnvBoolean = ( | ||
| value: string | undefined | ||
| ): boolean | undefined => { | ||
| if (value === undefined || value === '') return undefined; | ||
| const lower = value.toLowerCase(); | ||
| if (['true', '1', 'yes'].includes(lower)) return true; | ||
| if (['false', '0', 'no'].includes(lower)) return false; | ||
| return undefined; | ||
| }; | ||
|
|
||
| export const parseEnvNumber = ( | ||
| value: string | undefined | ||
| ): number | undefined => { | ||
| if (value === undefined || value === '') return undefined; | ||
| const num = Number(value); | ||
| return isNaN(num) ? undefined : num; | ||
| }; |
There was a problem hiding this comment.
The @pgpmjs/env mock implements the actual logic rather than using Jest mocks. This means tests are testing the real parseEnvBoolean and parseEnvNumber implementations instead of being able to control their behavior. Consider using jest.fn() mocks if you want to isolate the function being tested, or rename this file to indicate it's not a mock but a test implementation of the actual module.
| export const parseEnvBoolean = ( | |
| value: string | undefined | |
| ): boolean | undefined => { | |
| if (value === undefined || value === '') return undefined; | |
| const lower = value.toLowerCase(); | |
| if (['true', '1', 'yes'].includes(lower)) return true; | |
| if (['false', '0', 'no'].includes(lower)) return false; | |
| return undefined; | |
| }; | |
| export const parseEnvNumber = ( | |
| value: string | undefined | |
| ): number | undefined => { | |
| if (value === undefined || value === '') return undefined; | |
| const num = Number(value); | |
| return isNaN(num) ? undefined : num; | |
| }; | |
| export const parseEnvBoolean = jest.fn( | |
| (value: string | undefined): boolean | undefined => { | |
| if (value === undefined || value === '') return undefined; | |
| const lower = value.toLowerCase(); | |
| if (['true', '1', 'yes'].includes(lower)) return true; | |
| if (['false', '0', 'no'].includes(lower)) return false; | |
| return undefined; | |
| } | |
| ); | |
| export const parseEnvNumber = jest.fn( | |
| (value: string | undefined): number | undefined => { | |
| if (value === undefined || value === '') return undefined; | |
| const num = Number(value); | |
| return isNaN(num) ? undefined : num; | |
| } | |
| ); |
| '^@launchql/mjml$': '<rootDir>/tests/__mocks__/@launchql/mjml', | ||
| '^graphql-tag$': '<rootDir>/tests/__mocks__/graphql-tag' | ||
| }, | ||
| testTimeout: 30000 |
There was a problem hiding this comment.
The testTimeout is set to 30000ms (30 seconds) globally. This is quite long for unit tests and might hide performance issues. Consider using a shorter timeout for unit tests (e.g., 5000ms) and only increasing it for specific integration tests that need it using jest.setTimeout() within those test files.
| testTimeout: 30000 | |
| testTimeout: 5000 |
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '22' | ||
| - run: npm install -g pnpm@10.12.2 |
There was a problem hiding this comment.
The workflow uses a hardcoded pnpm version (10.12.2) which may become outdated. Consider using 'latest' or defining the version in a central location (like package.json packageManager field) that can be referenced by both local development and CI to ensure version consistency.
| job: { | ||
| jobId: 'test-job', | ||
| workerId: 'test-worker', | ||
| databaseId: 'databaseId' in options ? options.databaseId : 'test-db' |
There was a problem hiding this comment.
The createMockContext uses 'databaseId' as a key in options but checks for it using 'databaseId' in options on line 32. This works but the ternary condition could be simplified to options.databaseId ?? 'test-db' which is more idiomatic and handles both undefined and missing keys correctly.
| databaseId: 'databaseId' in options ? options.databaseId : 'test-db' | |
| databaseId: options.databaseId ?? 'test-db' |
| __dirname, | ||
| '../../generated/example/dist/index.js' | ||
| ); | ||
| const PORT = 19876; |
There was a problem hiding this comment.
The port 19876 is hardcoded but there's no check for port availability. If this port is already in use when tests run, the tests will fail with an unclear error. Consider using a random available port or documenting that this specific port is required for integration tests.
| const PORT = 19876; | |
| const PORT = 0; // use an ephemeral port to avoid conflicts |
| describe('send-email-link handler (GraphQL integration)', () => { | ||
| it.todo('invite_email fetches inviter and sends email with correct URL'); | ||
| it.todo('forgot_password constructs correct reset URL with tokens'); | ||
| it.todo('email_verification constructs correct verify URL'); | ||
| it.todo('dry-run mode logs but does not send email'); | ||
| it.todo('uses site theme primary color in email template'); | ||
| it.todo('handles localhost domains with correct protocol'); | ||
| }); |
There was a problem hiding this comment.
This file contains only TODO tests with no implementation. While it's acceptable to have placeholder tests for future GraphQL integration testing, consider either implementing at least one test or moving this to a separate tracking issue/document until the test infrastructure is ready. TODO tests can create noise in test output and may be forgotten.
| delete require.cache[require.resolve(modulePath)]; | ||
| const mod = require(modulePath); | ||
| const app = mod.default ?? mod; | ||
|
|
There was a problem hiding this comment.
The startFunction helper deletes the module from require.cache but doesn't handle potential errors during module loading. If the module path is incorrect or the module fails to load, the error will be unclear. Consider adding error handling and validation to provide better error messages during test debugging.
| delete require.cache[require.resolve(modulePath)]; | |
| const mod = require(modulePath); | |
| const app = mod.default ?? mod; | |
| let mod: any; | |
| let resolvedPath: string; | |
| try { | |
| resolvedPath = require.resolve(modulePath); | |
| delete require.cache[resolvedPath]; | |
| // eslint-disable-next-line @typescript-eslint/no-var-requires | |
| mod = require(resolvedPath); | |
| } catch (err: any) { | |
| const message = | |
| err && err.message | |
| ? err.message | |
| : String(err); | |
| throw new Error( | |
| `Failed to load module for startFunction from path "${modulePath}": ${message}` | |
| ); | |
| } | |
| const app = (mod as any).default ?? mod; | |
| if (!app || typeof (app as any).listen !== 'function') { | |
| throw new Error( | |
| `Module "${modulePath}" does not export an app with a "listen" method expected by startFunction.` | |
| ); | |
| } |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
I think these tests are not needed right now. i think we need to get the crd up and running before we can merge these tests. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 17 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| const EXAMPLE_MODULE = path.resolve( | ||
| __dirname, | ||
| '../../generated/example/dist/index.js' | ||
| ); | ||
| const PORT = 19876; | ||
|
|
||
| describe('fn-runtime HTTP layer', () => { | ||
| let url: string; | ||
| let close: () => Promise<void>; | ||
|
|
||
| beforeAll(async () => { | ||
| const started = await startFunction(EXAMPLE_MODULE, PORT); |
There was a problem hiding this comment.
The integration suite uses a fixed port (19876). This can lead to flaky failures when the port is already in use (especially in local/dev or when running Jest with parallelism). Prefer binding to an ephemeral port and deriving the URL from server.address(), or implement a simple free-port selection strategy.
| const EXAMPLE_MODULE = path.resolve( | |
| __dirname, | |
| '../../generated/example/dist/index.js' | |
| ); | |
| const PORT = 19876; | |
| describe('fn-runtime HTTP layer', () => { | |
| let url: string; | |
| let close: () => Promise<void>; | |
| beforeAll(async () => { | |
| const started = await startFunction(EXAMPLE_MODULE, PORT); | |
| import * as net from 'net'; | |
| const EXAMPLE_MODULE = path.resolve( | |
| __dirname, | |
| '../../generated/example/dist/index.js' | |
| ); | |
| async function getFreePort(): Promise<number> { | |
| return await new Promise((resolve, reject) => { | |
| const server = net.createServer(); | |
| .server.once('error', reject); | |
| server.listen(0, () => { | |
| const address = server.address(); | |
| if (!address || typeof address === 'string') { | |
| server.close(() => | |
| reject(new Error('Failed to determine free port from server address')) | |
| ); | |
| return; | |
| } | |
| const port = address.port; | |
| server.close(err => (err ? reject(err) : resolve(port))); | |
| }); | |
| }); | |
| } | |
| describe('fn-runtime HTTP layer', () => { | |
| let url: string; | |
| let close: () => Promise<void>; | |
| beforeAll(async () => { | |
| const port = await getFreePort(); | |
| const started = await startFunction(EXAMPLE_MODULE, port); |
| moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], | ||
| modulePathIgnorePatterns: ['dist/', 'generated/'], |
There was a problem hiding this comment.
modulePathIgnorePatterns includes dist/ and generated/, which can make modules under those paths non-requireable in Jest. The integration test loads generated/example/dist/index.js via require(), so this setting is likely to break the integration suite. Consider removing these entries from modulePathIgnorePatterns and using testPathIgnorePatterns (or narrower regexes) to avoid test discovery in those folders instead.
| moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], | |
| modulePathIgnorePatterns: ['dist/', 'generated/'], | |
| testPathIgnorePatterns: ['/dist/', '/generated/'], | |
| moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], |
|
Heads-up: repository history was rewritten on 2026-05-12 to scrub leaked secrets (Postgres/pgAdmin default passwords, an AWS access key ID, generated This PR shows "DIRTY" / merge-conflict status because git fetch --all --prune
git checkout <this-branch>
git reset --hard origin/<this-branch> # your local branch tip moved too — pick up the rewritten version
git rebase origin/main # rebase onto rewritten main
# resolve conflicts (usually trivial — mostly secret-placeholder + the 4 deleted interweb-*.yaml files)
git push --force-with-leaseOr merge instead of rebase if the branch is shared with others: git merge origin/main
git pushNotes:
|
No description provided.