I made the mistake of trying to write a test...
Yesterday I tried to write some unit tests for my durable objects. Simple, right?
WRONG.
On testing in general
To preface: I think tests are immensely valuable. Especially in the world of LLM generated code, I think tests are more valuable than ever. If you're wholesale replacing your code with the output of GPT (or for me, increasingly Claude), how can you verify that the behaviour has stayed the same?
Yeah, you can manually test. But that does not scale with codebase size. It wastes a lot of time.
A good test runs fast and does not test implementation detail. It checks the behaviour works programmatically. This is perfect for making sure that the massive chunk of code you just pasted in continues to work the same way that you used to (at least within the parameters of your assertions).
I think that people who use LLMs to generate their tests in any meaningful way (aside from as a sort of fancy autocomplete) are kind of missing the point. You should know exactly the behaviour you're trying to test. Though, for people labouring under code coverage targets, I feel your pain, as I think that's missing the point too.
What went wrong?
It started simple enough. I wanted to test my astro actions. This in and of itself ended up being a bit of pain as I had to separate my logic from my actions code, as there didn't seem to be a documented way to test actions.
For end to end tests, I'll probably use Playwright with Chromatic, to make sure things are hooked up right.
Anyway, as mentioned in my previous article, due to some Cloudflare limitations I have a separate worker with all my Durable Objects RPC.
Turns out, there's also another issue that makes this a pain for local dev. Essentially, I can't use my wrangler.toml file to define the bindings for the second worker, I need to duplicate that into my Vitest config. Sounds ripe for human error, thus time to automate it...
My workergen
script is getting incredibly large, but luckily I live for this shit... and also my trusty intern Claude has boundless energy.
The approach
We're already generating the wrangler.toml
for the auxiliary worker in workergen
(from the previous article). So we know what the bindings are already (by statically analyzing my Typescript code).
I'm not going to share intermediate code, because it's getting very large - but I'll talk through the issues I had.
First off, I was annoyed at the lack of typesafety, so I got it to add types to the generated worker. They're a bit rough-and-ready, but they work.
Then, I generated a vitest config. Easy enough right? I'll just add a workers
array, point it to my rpc_worker.ts
file.
Nope. It doesn't support TypeScript...
Okay, I'll build it with esbuild when I start my test. This more-or-less works (it does mean I don't have hot module replacement on my Durable Objects and have to rerun workergen).
But it's not the end of the world, esbuild is fast.
More problems
At this point I've spent like an hour on this. Cloudflare has a lot of work to do on DX, frankly. But it's worth it for the superior buttery smooth UX I'll have (it better be).
More fun occurs because I have a WASM module, so Claude writes me a WASM loader. Then some weird externals as the cloudflare:workers
import is not a 'real' import.
I don't give up. Eventually (after like 3 hours), I have a single unit test passing. Maybe I'll find more issues tonight.
The code
Here's the final code, complete with all my fixes.
import { promises as fs } from "fs";
import path from "path";
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
const objectsDir = "./src/objects";
const rpcWorkerPath = `${objectsDir}/rpc_worker.ts`;
const wranglerTomlPath = `${objectsDir}/wrangler.toml`;
const vitestConfigPath = "./vitest.config.ts";
interface DurableObjectMethod {
name: string;
paramCount: number;
}
interface DurableObjectClass {
className: string;
methods: DurableObjectMethod[];
}
async function generateRPCWorker() {
const files = await fs.readdir(objectsDir);
const durableObjectClasses: DurableObjectClass[] = [];
for (const file of files) {
if (file.endsWith(".ts") || file.endsWith(".js")) {
const filePath = path.join(objectsDir, file);
const content = await fs.readFile(filePath, "utf-8");
const ast = parse(content, {
sourceType: "module",
plugins: ["typescript"],
});
traverse(ast, {
ClassDeclaration(path) {
if (path.node.id && path.node.id.name) {
const className = path.node.id.name;
const methods: DurableObjectMethod[] = [];
path.traverse({
ClassMethod(methodPath) {
// @ts-ignore
const methodName = methodPath.node.key.name;
const isPrivate = methodPath.node.accessibility === "private";
// Ignore constructor, fetch, alarm methods, and private methods
if (
methodName !== "constructor" &&
methodName !== "fetch" &&
methodName !== "alarm" &&
!isPrivate
) {
const paramCount = methodPath.node.params.length;
methods.push({
name: methodName,
paramCount,
});
}
},
});
if (methods.length > 0 && className !== "RPCWorker") {
durableObjectClasses.push({ className, methods });
}
}
},
});
}
}
// Generate the RPC Worker content
let rpcWorkerContent = `import { WorkerEntrypoint } from "cloudflare:workers";\n\n`;
// Re-export and import the durable object classes
durableObjectClasses.forEach(({ className }) => {
rpcWorkerContent += `import type { ${className} } from "./${className}";\n`;
rpcWorkerContent += `export { ${className} } from "./${className}";\n`;
});
rpcWorkerContent += "\n";
// Generate Env interface with index signature
rpcWorkerContent += `type DurableObjects = {\n`;
durableObjectClasses.forEach(({ className }) => {
rpcWorkerContent += ` ${className.toUpperCase()}: DurableObjectNamespace<${className}>;\n`;
});
rpcWorkerContent += `};\n\n`;
rpcWorkerContent += `interface Env extends DurableObjects {\n`;
rpcWorkerContent += ` [key: string]: DurableObjectNamespace<any> | undefined;\n`;
rpcWorkerContent += `}\n\n`;
// Add type helpers for method inference
rpcWorkerContent += `type GetMethodParameters<T, M extends keyof T> = T[M] extends (...args: infer P) => any ? P : never;\n`;
rpcWorkerContent += `type GetMethodReturnType<T, M extends keyof T> = T[M] extends (...args: any[]) => infer R ? R : never;\n\n`;
// Start class definition
rpcWorkerContent += `export default class RPCWorker extends WorkerEntrypoint<Env> {\n`;
if (durableObjectClasses.length > 0) {
rpcWorkerContent += `\n async newUniqueId(obj: string): Promise<string> {\n`;
rpcWorkerContent += ` const namespace = this.env[obj.toUpperCase()];\n`;
rpcWorkerContent += ` if (!namespace) throw new Error(\`Invalid object type: \${obj}\`);\n`;
rpcWorkerContent += ` return namespace.newUniqueId().toString();\n`;
rpcWorkerContent += ` }\n\n`;
}
rpcWorkerContent += ` async idFromName(obj: string, name: string): Promise<string> {\n`;
rpcWorkerContent += ` const namespace = this.env[obj.toUpperCase()];\n`;
rpcWorkerContent += ` if (!namespace) throw new Error(\`Invalid object type: \${obj}\`);\n`;
rpcWorkerContent += ` return namespace.idFromName(name).toString();\n`;
rpcWorkerContent += ` }\n\n`;
// Generate methods for each Durable Object class and its methods
durableObjectClasses.forEach((durableObject) => {
durableObject.methods.forEach((method) => {
const params =
method.paramCount > 0
? `id: string, ...args: GetMethodParameters<${durableObject.className}, "${method.name}">`
: `id: string`;
rpcWorkerContent += ` async ${durableObject.className.toUpperCase()}_${
method.name
}(${params}): Promise<GetMethodReturnType<${durableObject.className}, "${
method.name
}">> {\n`;
rpcWorkerContent += ` const durableId = this.env.${durableObject.className.toUpperCase()}.idFromString(id);\n`;
rpcWorkerContent += ` const obj = this.env.${durableObject.className.toUpperCase()}.get(durableId);\n`;
rpcWorkerContent += ` return obj.${method.name}(${
method.paramCount > 0 ? "...args" : ""
});\n`;
rpcWorkerContent += ` }\n\n`;
});
});
rpcWorkerContent += `}\n`;
// Write the rpc_worker.ts file
await fs.writeFile(rpcWorkerPath, rpcWorkerContent, "utf-8");
console.log(`Generated ${rpcWorkerPath}`);
return durableObjectClasses;
}
async function generateWranglerToml(
durableObjectClasses: DurableObjectClass[]
) {
const durableObjectBindings = durableObjectClasses.map(
(doc) => doc.className
);
// Generate wrangler.toml content
let wranglerTomlContent = `# Auto-generated wrangler.toml\n\n`;
wranglerTomlContent += `name = "rpc_worker"\n`;
wranglerTomlContent += `compatibility_date = "2024-09-25"\n`;
wranglerTomlContent += `main = "./rpc_worker.ts"\n\n`;
wranglerTomlContent += `[[migrations]]\n`;
wranglerTomlContent += `tag = "v1"\n`;
wranglerTomlContent += `new_sqlite_classes = [${durableObjectBindings
.map((name) => `"${name}"`)
.join(", ")}]\n\n`;
wranglerTomlContent += `[durable_objects]\n`;
wranglerTomlContent += `bindings = [\n`;
durableObjectBindings.forEach((binding, index) => {
wranglerTomlContent += ` { name = "${binding.toUpperCase()}", class_name = "${binding}" }`;
if (index !== durableObjectBindings.length - 1) {
wranglerTomlContent += ",\n";
} else {
wranglerTomlContent += "\n";
}
});
wranglerTomlContent += `]\n`;
await fs.writeFile(wranglerTomlPath, wranglerTomlContent, "utf-8");
console.log(`Generated ${wranglerTomlPath}`);
}
async function generateVitestConfig(durableObjectClasses: DurableObjectClass[]) {
// Create the DO bindings object
const doBindingsObject = durableObjectClasses
.map(({ className }) => ` ${className.toUpperCase()}: { className: "${className}", scriptName: "rpc_worker" }`)
.join(",\n");
let vitestConfigContent = `import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config";\n`;
vitestConfigContent += `import * as esbuild from 'esbuild';\n`;
vitestConfigContent += `import { readFileSync } from 'fs';\n`;
vitestConfigContent += `import { join } from 'path';\n\n`;
// Add WASM loader helper
vitestConfigContent += `const wasmLoader: esbuild.Plugin = {\n`;
vitestConfigContent += ` name: 'wasm-loader',\n`;
vitestConfigContent += ` setup(build: esbuild.PluginBuild) {\n`;
vitestConfigContent += ` build.onLoad({ filter: /\.wasm$/ }, async (args: esbuild.OnLoadArgs) => {\n`;
vitestConfigContent += ` const wasmModule = readFileSync(args.path);\n`;
vitestConfigContent += ` const wasmBase64 = wasmModule.toString('base64');\n`;
vitestConfigContent += ` return {\n`;
vitestConfigContent += ` contents: \`\n`;
vitestConfigContent += ` const wasmBase64 = "\${wasmBase64}";\n`;
vitestConfigContent += ` const wasmBytes = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));\n`;
vitestConfigContent += ` export default wasmBytes;\n`;
vitestConfigContent += ` \`,\n`;
vitestConfigContent += ` loader: 'js'\n`;
vitestConfigContent += ` };\n`;
vitestConfigContent += ` });\n`;
vitestConfigContent += ` }\n`;
vitestConfigContent += `};\n\n`;
// Add function to build the script
vitestConfigContent += `async function buildWorkerScript() {\n`;
vitestConfigContent += ` const outfile = join(process.cwd(), 'dist', '_rpc_worker.js');\n`;
vitestConfigContent += ` await esbuild.build({\n`;
vitestConfigContent += ` entryPoints: ['./src/objects/rpc_worker.ts'],\n`;
vitestConfigContent += ` outfile,\n`;
vitestConfigContent += ` bundle: true,\n`;
vitestConfigContent += ` format: 'esm',\n`;
vitestConfigContent += ` target: 'es2022',\n`;
vitestConfigContent += ` minify: false,\n`;
vitestConfigContent += ` platform: 'browser',\n`;
vitestConfigContent += ` plugins: [\n`;
vitestConfigContent += ` wasmLoader,\n`;
vitestConfigContent += ` ],\n`;
vitestConfigContent += ` external:["cloudflare:workers"]\n`;
vitestConfigContent += ` });\n`;
vitestConfigContent += ` return outfile;\n`;
vitestConfigContent += `}\n\n`;
vitestConfigContent += `export default defineWorkersProject({\n`;
vitestConfigContent += ` test: {\n`;
vitestConfigContent += ` poolOptions: {\n`;
vitestConfigContent += ` workers: {\n`;
vitestConfigContent += ` singleWorker: true,\n`;
vitestConfigContent += ` wrangler: { configPath: "./wrangler.toml" },\n`;
vitestConfigContent += ` miniflare: {\n`;
vitestConfigContent += ` compatibilityDate: "2024-09-25",\n`;
vitestConfigContent += ` compatibilityFlags: ["nodejs_compat_v2", "nodejs_compat"],\n`;
vitestConfigContent += ` workers: [\n`;
vitestConfigContent += ` {\n`;
vitestConfigContent += ` name: "rpc_worker",\n`;
vitestConfigContent += ` modules: true,\n`;
vitestConfigContent += ` scriptPath: await buildWorkerScript(),\n`;
vitestConfigContent += ` durableObjects: {\n${doBindingsObject}\n }\n`;
vitestConfigContent += ` }\n`;
vitestConfigContent += ` ]\n`;
vitestConfigContent += ` }\n`;
vitestConfigContent += ` }\n`;
vitestConfigContent += ` }\n`;
vitestConfigContent += ` }\n`;
vitestConfigContent += `});\n`;
await fs.writeFile(vitestConfigPath, vitestConfigContent, "utf-8");
console.log(`Generated ${vitestConfigPath}`);
}
// Execute all generators
(async () => {
try {
const durableObjectClasses = await generateRPCWorker();
await generateWranglerToml(durableObjectClasses);
await generateVitestConfig(durableObjectClasses);
console.log("All files generated successfully!");
} catch (error) {
console.error("Error generating files:", error);
process.exit(1);
}
})();