neon-testing 1.0.2 โ†’ 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +111 -26
  2. package/index.ts +147 -12
  3. package/package.json +11 -8
  4. package/singleton.ts +10 -0
package/README.md CHANGED
@@ -1,15 +1,18 @@
1
1
  # Neon testing
2
2
 
3
- A Vitest utility for automated integration tests with [Neon](https://neon.com/).
3
+ [![Integration tests](https://github.com/starmode-base/neon-testing/actions/workflows/test.yml/badge.svg)](https://github.com/starmode-base/neon-testing/actions/workflows/test.yml)
4
4
 
5
- Each test file runs against its own isolated PostgreSQL database (Neon branch), ensuring clean, parallel, and reproducible testing of code that relies on a database. Because it uses a real database, you can test code logic that depends on database features such as transaction rollbacks, unique constraints, and more.
5
+ A [Vitest](https://vitest.dev/) utility for seamless integration tests with [Neon Postgres](https://neon.com/).
6
6
 
7
- Using an actual clone of your production database for integration testing lets you verify functionality that mocks cannot.
7
+ Each test file runs against its own isolated PostgreSQL database (Neon branch), ensuring clean, parallel, and reproducible testing of code that interacts with a database. Because it uses a real, isolated clone of your production database, you can test code logic that depends on database features, such as transaction rollbacks, unique constraints, and more.
8
+
9
+ **Testing against a clone of your production database lets you verify functionality that mocks cannot.**
8
10
 
9
11
  ## Features
10
12
 
11
13
  - ๐Ÿ”„ **Isolated test environments** - Each test file runs against its own Postgres database with your actual schema and constraints
12
14
  - ๐Ÿงน **Automatic cleanup** - Neon test branches are created and destroyed automatically
15
+ - ๐Ÿ› **Debug friendly** - Option to preserve test branches for debugging failed tests
13
16
  - ๐Ÿ›ก๏ธ **TypeScript native** - No JavaScript support
14
17
  - ๐ŸŽฏ **ESM only** - No CommonJS support
15
18
 
@@ -20,12 +23,20 @@ Using an actual clone of your production database for integration testing lets y
20
23
  1. **Test execution**: Your tests run against the isolated database
21
24
  1. **Cleanup**: After tests complete, the branch is automatically deleted
22
25
 
26
+ ### Test isolation
27
+
28
+ Tests within a test file share the same database instance (Neon branch), so while all test files are isolated, tests within a test file are intentionally not.
29
+
30
+ This works because Vitest runs test files in parallel, but tests within each test file run sequentially one at a time.
31
+
32
+ If you prefer individual tests within a test file to be isolated, [simply clean up the database in a beforeEach lifecycle](examples/isolated.test.ts).
33
+
23
34
  ## Quick start
24
35
 
25
36
  ### Prerequisites
26
37
 
27
38
  - A [Neon project](https://console.neon.tech/app/projects) with a database
28
- - A [Neon API key](https://neon.tech/docs/manage/api-keys) for programmatic access
39
+ - A [Neon API key](https://neon.com/docs/manage/api-keys) for programmatic access
29
40
 
30
41
  ### Install
31
42
 
@@ -36,13 +47,18 @@ bun add -d neon-testing
36
47
  ### Minimal example
37
48
 
38
49
  ```typescript
39
- // database.test.ts
50
+ // minimal.test.ts
40
51
  import { expect, test } from "vitest";
41
52
  import { makeNeonTesting } from "neon-testing";
42
53
  import { Pool } from "@neondatabase/serverless";
43
54
 
44
55
  // Enable Neon test branch for this test file
45
- makeNeonTesting({ apiKey: "apiKey", projectId: "projectId" })();
56
+ makeNeonTesting({
57
+ apiKey: "apiKey",
58
+ projectId: "projectId",
59
+ // Recommended for Neon WebSocket drivers to automatically close connections
60
+ autoCloseWebSockets: true,
61
+ })();
46
62
 
47
63
  test("database operations", async () => {
48
64
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
@@ -52,8 +68,6 @@ test("database operations", async () => {
52
68
 
53
69
  const users = await pool.query(`SELECT * FROM users`);
54
70
  expect(users.rows).toStrictEqual([{ id: 1, name: "Ellen Ripley" }]);
55
-
56
- await pool.end();
57
71
  });
58
72
  ```
59
73
 
@@ -74,20 +88,21 @@ export const withNeonTestBranch = makeNeonTesting({
74
88
  });
75
89
  ```
76
90
 
77
- See all available options in [NeonTestingOptions](https://github.com/starmode-base/neon-testing/blob/main/index.ts#L30-L41).
78
-
79
91
  #### 2. Enable database testing
80
92
 
81
93
  Then call the exported test lifecycle function in the test files where you need database access.
82
94
 
83
95
  ```typescript
84
- // database.test.ts
96
+ // recommended.test.ts
85
97
  import { expect, test } from "vitest";
86
98
  import { withNeonTestBranch } from "./test-setup";
87
99
  import { Pool } from "@neondatabase/serverless";
88
100
 
89
101
  // Enable Neon test branch for this test file
90
- withNeonTestBranch();
102
+ withNeonTestBranch({
103
+ // Recommended for Neon WebSocket drivers to automatically close connections
104
+ autoCloseWebSockets: true,
105
+ });
91
106
 
92
107
  test("database operations", async () => {
93
108
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
@@ -97,14 +112,51 @@ test("database operations", async () => {
97
112
 
98
113
  const users = await pool.query(`SELECT * FROM users`);
99
114
  expect(users.rows).toStrictEqual([{ id: 1, name: "Ellen Ripley" }]);
115
+ });
116
+ ```
117
+
118
+ ## Drivers
119
+
120
+ This library works with any database driver that supports Neon Postgres and Vitest. The examples below demonstrate connection management, transaction support, and test isolation patterns for some popular drivers.
121
+
122
+ **IMPORTANT:** For [Neon WebSocket drivers](https://neon.com/docs/serverless/serverless-driver), enable `autoCloseWebSockets` in your `makeNeonTesting()` or `withNeonTestBranch()` configuration. This automatically closes WebSocket connections when deleting test branches, preventing connection termination errors.
123
+
124
+ ### Examples
125
+
126
+ - [Neon serverless WebSocket](examples/drivers/ws-neon.test.ts)
127
+ - [Neon serverless WebSocket + Drizzle](examples/drivers/ws-neon-drizzle.test.ts)
128
+ - [Neon serverless HTTP](examples/drivers/http-neon.test.ts)
129
+ - [Neon serverless HTTP + Drizzle](examples/drivers/http-neon-drizzle.test.ts)
130
+ - [node-postgres](examples/drivers/tcp-pg.test.ts)
131
+ - [node-postgres + Drizzle](examples/drivers/tcp-pg-drizzle.test.ts)
132
+ - [Postgres.js](examples/drivers/tcp-postgres.test.ts)
133
+ - [Postgres.js + Drizzle](examples/drivers/tcp-postgres-drizzle.test.ts)
134
+
135
+ ## Configuration
136
+
137
+ You configure neon-testing in two places:
138
+
139
+ - **Base settings** in `makeNeonTesting()`
140
+ - **Optional overrides** in `withNeonTestBranch()`
141
+
142
+ See all available options in [NeonTestingOptions](https://github.com/starmode-base/neon-testing/blob/main/index.ts#L33-L75).
143
+
144
+ ### Base configuration
145
+
146
+ Configure the base settings in `makeNeonTesting()`:
147
+
148
+ ```typescript
149
+ import { makeNeonTesting } from "neon-testing";
100
150
 
101
- await pool.end();
151
+ export const withNeonTestBranch = makeNeonTesting({
152
+ apiKey: "apiKey",
153
+ projectId: "projectId",
102
154
  });
103
155
  ```
104
156
 
105
- #### Override configuration
157
+ ### Override configuration
106
158
 
107
- Branch from a specific branch instead of main:
159
+ Override the base configuration in specific test files with `withNeonTestBranch()`:
108
160
 
109
161
  ```typescript
110
162
  import { withNeonTestBranch } from "./test-setup";
@@ -112,34 +164,67 @@ import { withNeonTestBranch } from "./test-setup";
112
164
  withNeonTestBranch({ parentBranchId: "br-staging-123" });
113
165
  ```
114
166
 
115
- Don't copy data when branching:
167
+ ## Utilities
168
+
169
+ ### deleteAllTestBranches()
170
+
171
+ The `deleteAllTestBranches()` function is a utility that deletes all test branches from your Neon project. This is useful for cleanup when tests fail unexpectedly and leave orphaned test branches.
116
172
 
117
173
  ```typescript
118
174
  import { withNeonTestBranch } from "./test-setup";
119
175
 
120
- withNeonTestBranch({ schemaOnly: true });
176
+ // Access the cleanup utility
177
+ await withNeonTestBranch.deleteAllTestBranches();
121
178
  ```
122
179
 
123
- See all available options in [NeonTestingOptions](https://github.com/starmode-base/neon-testing/blob/main/index.ts#L30-L41).
180
+ The function identifies test branches by looking for the `integration-test: true` annotation that neon-testing automatically adds to all test branches it creates.
181
+
182
+ ### lazySingleton()
183
+
184
+ The `lazySingleton()` function creates a lazy singleton from a factory function. This is useful for managing database connections efficiently:
124
185
 
125
- ## Isolate individual tests
186
+ ```typescript
187
+ import { lazySingleton } from "neon-testing";
188
+ import { neon } from "@neondatabase/serverless";
126
189
 
127
- Tests within a single test file share the same database instance (Neon branch), so while all test files are isolated, tests within a test file are not. If you prefer individual tests within a test file to be isolated, [simply clean up the database in a beforeEach lifecycle](examples/neon-serverless-http-isolated.test.ts).
190
+ const sql = lazySingleton(() => neon(process.env.DATABASE_URL!));
128
191
 
129
- This works because Vitest runs test files in parallel, but tests within each test file run one at a time.
192
+ // The connection is only created when first called
193
+ test("database operations", async () => {
194
+ const users = await sql()`SELECT * FROM users`;
195
+ // ...
196
+ });
197
+ ```
130
198
 
131
199
  ## Contributing
132
200
 
133
201
  Contributions are welcome! Please open issues or pull requests on [GitHub](https://github.com/starmode-base/neon-testing/pulls).
134
202
 
203
+ ### Environment
204
+
205
+ To run tests locally, create an `.env` file in the project root with these keys:
206
+
207
+ - `NEON_API_KEY="***"`
208
+ - `NEON_PROJECT_ID="***"`
209
+
210
+ Create a free Neon project at [neon.com](https://neon.com/) to test with.
211
+
212
+ ### Release
213
+
214
+ To make a new release, run:
215
+
216
+ ```sh
217
+ bun run release
218
+ ```
219
+
220
+ The command will abort if there are uncommitted changes in the working tree, or if the `version` in [package.json](package.json) has not been incremented.
221
+
135
222
  ## License
136
223
 
137
224
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
138
225
 
139
- ## Need expert help?
140
-
141
- Hi, I'm [@lirbank](https://github.com/lirbank). I take on a few consulting projects each year where I help companies build, unblock, and ship. Here's what I do:
226
+ ## Need help?
142
227
 
143
- **[STฮ›R MODฮž](https://www.starmode.dev/)** โ€” A boutique AI development studio I run with AI/ML expert and data scientist [@spencer-g-smith](https://github.com/spencer-g-smith). We help companies build accurate AI solutions: AI-first apps, advanced workflows, and agentic systems.
228
+ Hi, Iโ€™m [Mikael Lirbank](https://www.lirbank.com/). I help teams build reliable AI systems. I care about qualityโ€”AI evals, robust test suites, clean data models, and clean architecture. Sometimes I draw user interfaces.
144
229
 
145
- **[Mikael Lirbank](https://www.lirbank.com/)** โ€” My solo practice, focused on web app development, test automation, code quality, and technical architecture. I'm friendly and happy to help with the hard stuff.
230
+ Want to ship faster without breaking things? Letโ€™s talk.
package/index.ts CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  type ConnectionDetails,
8
8
  } from "@neondatabase/api-client";
9
9
  import { afterAll, beforeAll } from "vitest";
10
+ import { neonConfig } from "@neondatabase/serverless";
11
+
12
+ export { lazySingleton } from "./singleton";
10
13
 
11
14
  /**
12
15
  * Creates a PostgreSQL connection URI from connection parameters
@@ -28,16 +31,47 @@ function createConnectionUri(
28
31
  }
29
32
 
30
33
  export interface NeonTestingOptions {
31
- /** The Neon API key, this is used to create and teardown test branches */
34
+ /**
35
+ * The Neon API key, this is used to create and teardown test branches
36
+ *
37
+ * https://neon.com/docs/manage/api-keys#creating-api-keys
38
+ */
32
39
  apiKey: string;
33
- /** The Neon project ID to operate on */
40
+ /**
41
+ * The Neon project ID to operate on
42
+ *
43
+ * https://console.neon.tech/app/projects
44
+ */
34
45
  projectId: string;
35
- /** The parent branch ID for the new branch */
46
+ /**
47
+ * The parent branch ID for the new branch. If omitted or empty, the branch
48
+ * will be created from the project's default branch.
49
+ */
36
50
  parentBranchId?: string;
37
- /** Whether to create a schema-only branch (default: false) */
51
+ /**
52
+ * Whether to create a schema-only branch (default: false)
53
+ */
38
54
  schemaOnly?: boolean;
39
- /** The type of connection to create (pooler is recommended) */
55
+ /**
56
+ * The type of connection to create (pooler is recommended)
57
+ */
40
58
  endpoint?: "pooler" | "direct";
59
+ /**
60
+ * Delete the test branch in afterAll (default: true)
61
+ *
62
+ * Disabling this will leave each test branch in the Neon project after the
63
+ * test suite runs
64
+ */
65
+ deleteBranch?: boolean;
66
+ /**
67
+ * Automatically close Neon WebSocket connections opened during tests before
68
+ * deleting the branch (default: false)
69
+ *
70
+ * Suppresses the specific Neon WebSocket "Connection terminated unexpectedly"
71
+ * error that may surface when deleting a branch with open WebSocket
72
+ * connections
73
+ */
74
+ autoCloseWebSockets?: boolean;
41
75
  }
42
76
 
43
77
  /** Options for overriding test database setup (excludes apiKey) */
@@ -47,11 +81,12 @@ export type NeonTestingOverrides = Omit<Partial<NeonTestingOptions>, "apiKey">;
47
81
  * Factory function that creates a Neon test database setup/teardown function
48
82
  * for Vitest test suites.
49
83
  *
50
- * @param apiKey - The Neon API key
51
- * @param projectId - The Neon project ID
52
- * @param endpoint - The type of connection to create (pooler or direct)
53
- * @param parentBranchId - The parent branch ID for the new branch
54
- * @param schemaOnly - Whether to create a schema-only branch
84
+ * @param apiKey - The Neon API key, this is used to create and teardown test branches
85
+ * @param projectId - The Neon project ID to operate on
86
+ * @param parentBranchId - The parent branch ID for the new branch. If omitted or empty, the branch will be created from the project's default branch.
87
+ * @param schemaOnly - Whether to create a schema-only branch (default: false)
88
+ * @param endpoint - The type of connection to create (pooler is recommended)
89
+ * @param deleteBranch - Delete the test branch in afterAll (default: true). Disabling this will leave each test branch in the Neon project after the test suite runs
55
90
  * @returns A setup/teardown function for Vitest test suites
56
91
  *
57
92
  * Side effects:
@@ -93,6 +128,21 @@ export function makeNeonTesting(factoryOptions: NeonTestingOptions) {
93
128
  // Each test file gets its own branch ID and database client
94
129
  let branchId: string | undefined;
95
130
 
131
+ // List of tracked Neon WebSocket connections
132
+ const neonSockets = new Set<WebSocket>();
133
+
134
+ // Custom WebSocket constructor that tracks Neon WebSocket connections
135
+ class TrackingWebSocket extends WebSocket {
136
+ constructor(url: string) {
137
+ super(url);
138
+
139
+ // Only track Neon WebSocket connections
140
+ if (!url.includes(".neon.tech/")) return;
141
+
142
+ neonSockets.add(this);
143
+ }
144
+ }
145
+
96
146
  /**
97
147
  * Create a new test branch
98
148
  *
@@ -114,6 +164,7 @@ export function makeNeonTesting(factoryOptions: NeonTestingOptions) {
114
164
  branchId = data.branch.id;
115
165
 
116
166
  const [connectionUri] = data.connection_uris ?? [];
167
+
117
168
  if (!connectionUri) {
118
169
  throw new Error("No connection URI found");
119
170
  }
@@ -134,12 +185,33 @@ export function makeNeonTesting(factoryOptions: NeonTestingOptions) {
134
185
  }
135
186
 
136
187
  beforeAll(async () => {
137
- process.env.DATABASE_URL = await createBranch();
188
+ process.env.DATABASE_URL = await withRetry(createBranch, {
189
+ maxRetries: 5,
190
+ baseDelayMs: 1000,
191
+ });
192
+
193
+ if (options.autoCloseWebSockets) {
194
+ // Install a custom WebSocket constructor that tracks Neon WebSocket
195
+ // connections and closes them before deleting the branch
196
+ neonConfig.webSocketConstructor = TrackingWebSocket;
197
+ }
138
198
  });
139
199
 
140
200
  afterAll(async () => {
141
- await deleteBranch();
142
201
  process.env.DATABASE_URL = undefined;
202
+
203
+ // Close all tracked Neon WebSocket connections before deleting the branch
204
+ if (options.autoCloseWebSockets) {
205
+ // Suppress Neon WebSocket "Connection terminated unexpectedly" error
206
+ process.prependListener("uncaughtException", neonWsErrorHandler);
207
+
208
+ // Close tracked Neon WebSocket connections before deleting the branch
209
+ neonSockets.forEach((ws) => ws.close());
210
+ }
211
+
212
+ if (options.deleteBranch !== false) {
213
+ await deleteBranch();
214
+ }
143
215
  });
144
216
  };
145
217
 
@@ -148,3 +220,66 @@ export function makeNeonTesting(factoryOptions: NeonTestingOptions) {
148
220
 
149
221
  return testDbSetup;
150
222
  }
223
+
224
+ /**
225
+ * Error handler: Suppress Neon WebSocket "Connection terminated unexpectedly"
226
+ * error
227
+ */
228
+ const neonWsErrorHandler = (error: Error) => {
229
+ const isNeonWsClose =
230
+ error.message.includes("Connection terminated unexpectedly") &&
231
+ error.stack?.includes("@neondatabase/serverless");
232
+
233
+ if (isNeonWsClose) {
234
+ // Swallow this specific Neon WS termination error
235
+ return;
236
+ }
237
+
238
+ // For any other error, detach and rethrow
239
+ throw error;
240
+ };
241
+
242
+ /**
243
+ * Reusable API call wrapper with automatic retry on 423 errors with exponential
244
+ * backoff
245
+ *
246
+ * https://neon.com/docs/reference/typescript-sdk#error-handling
247
+ * https://neon.com/docs/changelog/2022-07-20
248
+ */
249
+ async function withRetry<T>(
250
+ fn: () => Promise<T>,
251
+ options: {
252
+ maxRetries: number;
253
+ baseDelayMs: number;
254
+ },
255
+ ): Promise<T> {
256
+ if (!Number.isInteger(options.maxRetries) || options.maxRetries <= 0) {
257
+ throw new Error("maxRetries must be a positive integer");
258
+ }
259
+
260
+ if (!Number.isInteger(options.baseDelayMs) || options.baseDelayMs <= 0) {
261
+ throw new Error("baseDelayMs must be a positive integer");
262
+ }
263
+
264
+ for (let attempt = 1; attempt <= options.maxRetries; attempt++) {
265
+ try {
266
+ return await fn();
267
+ } catch (error: any) {
268
+ const status = error?.response?.status;
269
+
270
+ if (status === 423 && attempt < options.maxRetries) {
271
+ const delay = options.baseDelayMs * Math.pow(2, attempt - 1);
272
+
273
+ console.log(
274
+ `API call failed with 423, retrying in ${delay}ms (attempt ${attempt}/${options.maxRetries})`,
275
+ );
276
+ await new Promise((resolve) => setTimeout(resolve, delay));
277
+
278
+ continue;
279
+ }
280
+
281
+ throw error;
282
+ }
283
+ }
284
+ throw new Error("apiCallWithRetry reached unexpected end");
285
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "neon-testing",
3
- "version": "1.0.2",
4
- "description": "A Vitest utility for automated integration tests with Neon",
3
+ "version": "1.1.1",
4
+ "description": "A Vitest utility for seamless integration tests with Neon Postgres",
5
5
  "keywords": [
6
6
  "neon",
7
7
  "postgres",
@@ -17,7 +17,8 @@
17
17
  "module": "index.ts",
18
18
  "type": "module",
19
19
  "files": [
20
- "index.ts"
20
+ "index.ts",
21
+ "singleton.ts"
21
22
  ],
22
23
  "scripts": {
23
24
  "test": "vitest",
@@ -27,17 +28,19 @@
27
28
  "postpublish": "git tag v$(bun -p \"require('./package.json').version\") && git push --tags"
28
29
  },
29
30
  "dependencies": {
30
- "@neondatabase/api-client": "^2.1.0"
31
+ "@neondatabase/api-client": "^2.2.0"
31
32
  },
32
33
  "peerDependencies": {
33
- "typescript": "^5.8.3",
34
34
  "vitest": "^3.2.4"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@neondatabase/serverless": "^1.0.1",
38
- "dotenv": "^16.5.0",
39
- "pg": "^8.16.2",
38
+ "dotenv": "^17.2.1",
39
+ "drizzle-orm": "^0.44.4",
40
+ "pg": "^8.16.3",
40
41
  "postgres": "^3.4.7",
41
- "prettier": "^3.5.3"
42
+ "prettier": "^3.6.2",
43
+ "typescript": "^5.9.2",
44
+ "vitest": "^3.2.4"
42
45
  }
43
46
  }
package/singleton.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Create a lazy singleton from a factory function
3
+ */
4
+ export function lazySingleton<T>(factory: () => T): () => T {
5
+ let instance: T | undefined;
6
+ return () => {
7
+ instance ??= factory();
8
+ return instance;
9
+ };
10
+ }