11 KiB
Test Writing Guide
Test Style
- Always use
test.concurrent()- self-contained tests that can run in parallel - Never use
describe/beforeAll/test- avoid shared state between tests - Keep setup inline - each test should be fully self-contained
Quick Start
Use initScenario with the scenario builder (s.*) for test setup:
import { expect, test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("my-feature: descriptive test name")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "my-unique-test-id",
setup: [
s.customer({ paymentMethod: "success" }), // testClock defaults to true
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Your test logic here
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(400);
});
Fixtures
Item Fixtures (@tests/utils/fixtures/items)
Pre-configured product items for common feature types:
import { items } from "@tests/utils/fixtures/items.js";
| Item | Description | Default |
|---|---|---|
items.dashboard() |
Boolean feature (on/off) | - |
items.monthlyMessages({ includedUsage }) |
Resets monthly | 100 |
items.monthlyWords({ includedUsage }) |
Resets monthly | 100 |
items.monthlyCredits({ includedUsage }) |
Resets monthly | 100 |
items.unlimitedMessages() |
No usage cap | - |
items.lifetimeMessages({ includedUsage }) |
Never resets | 100 |
items.prepaidMessages({ includedUsage }) |
Buy upfront ($10/unit) | 0 |
items.consumableMessages({ includedUsage }) |
Pay-per-use ($0.10/unit) | 0 |
items.allocatedUsers({ includedUsage }) |
Prorated seats ($10/seat) | 0 |
Product Fixtures (@tests/utils/fixtures/products)
import { products } from "@tests/utils/fixtures/products.js";
| Product | Description |
|---|---|
products.base({ items, id?, isDefault? }) |
No base price. Defaults: id="base", isDefault=false |
products.pro({ items, id? }) |
Includes $20/mo base price - don't add monthlyPrice(). Default: id="pro" |
products.proAnnual({ items, id? }) |
Includes $200/yr base price. Default: id="pro-annual" |
Example:
// Free product (no price)
const free = products.base({ items: [items.monthlyMessages()] });
// Pro product - already has $20/mo, just add features
const pro = products.pro({ items: [items.monthlyMessages()] });
Scenario Builder (initScenario) - Recommended
Use functional composition with setup and actions arrays for flexible test configuration:
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
const { customerId, autumnV1, autumnV2, ctx, testClockId, entities } = await initScenario({
customerId: "my-test",
setup: [
s.customer({ paymentMethod: "success" }), // testClock is true by default
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }), // optional
],
actions: [
s.attach({ productId: "pro", entityIndex: 0 }),
s.attach({ productId: "free", entityIndex: 1 }),
s.advanceTestClock({ days: 15 }), // optional
],
});
// entities[0].id = "ent-1", entities[1].id = "ent-2"
Setup Methods (s.*)
| Method | Purpose |
|---|---|
s.customer({ paymentMethod?, data?, withDefault?, testClock? }) |
Customer options. testClock defaults to true - don't pass it unless disabling |
s.products({ list }) |
Products to create |
s.entities({ count, featureId }) |
Auto-generate entities (ids: "ent-1", "ent-2", ...) |
Note:
testClockdefaults totrue- you don't need to passtestClock: truein most tests.
Action Methods (s.*)
| Method | Purpose |
|---|---|
s.attach({ productId, entityIndex? }) |
Attach product (omit entityIndex for customer-level) |
s.cancel({ productId, entityIndex? }) |
Cancel product subscription |
s.advanceTestClock({ days?, weeks?, hours?, months?, toNextInvoice? }) |
Advance test clock after attachments |
Examples
Simple test (no entities):
const { customerId, autumnV1 } = await initScenario({
customerId: "simple-test",
setup: [
s.customer({}), // testClock defaults to true
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
With payment method:
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "paid-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
With entities:
const { customerId, autumnV1, entities } = await initScenario({
customerId: "entity-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: "pro", entityIndex: 0 }),
s.attach({ productId: "free", entityIndex: 1 }),
],
});
// entities[0].id = "ent-1", entities[1].id = "ent-2"
With clock advancement:
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "clock-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: "pro" }),
s.advanceTestClock({ days: 15 }),
],
});
Test Clocks
Critical: Date.now() does NOT change when using s.advanceTestClock. Always use advancedTo from initScenario.
const { advancedTo } = await initScenario({
actions: [
s.attach({ productId: pro.id }),
s.advanceTestClock({ days: 3 }),
],
});
// ❌ WRONG - Date.now() is still real time
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(4));
// ✅ CORRECT - Use advancedTo (Stripe test clock's current time)
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(4));
advancedTo is the Unix timestamp (ms) of the Stripe test clock after all s.advanceTestClock actions complete.
Product ID in s.attach()
Important: Always use the product variable's .id property in s.attach(), never a string literal.
const free = products.base({ items: [messagesItem] });
const pro = products.pro({ items: [messagesItem] });
// ✅ GOOD - Use product.id
actions: [
s.attach({ productId: free.id }),
s.attach({ productId: pro.id }),
]
// ❌ BAD - Don't use string literals
actions: [
s.attach({ productId: "base" }), // Wrong!
s.attach({ productId: "pro" }), // Wrong!
]
This ensures consistency and prevents bugs when product IDs change. The same product.id is used for both s.attach() and subsequent API calls.
Prepaid Items
Prepaid items require a quantity in options when attaching or updating:
const prepaidItem = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100, // 1 pack = 100 units
price: 10, // $10 per pack
});
Key Rules
quantityis the total units you want - NOT multiplied by billing unitsquantityis separate fromincluded_usage- included_usage provides free balance, quantity is purchased balance- Balance = included_usage + quantity - usage
Attaching Prepaid Products
// Attach with 200 units purchased (2 packs)
await initScenario({
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
Updating Prepaid Quantities
// Upgrade from 200 to 500 units
const updateParams = {
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 500 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// preview.total = (5 packs - 2 packs) * $10 = $30
await autumnV1.subscriptions.update(updateParams);
Prepaid Billing Logic
On update, the system:
- Refunds previous prepaid:
old_packs * old_price - Charges new prepaid:
new_packs * new_price preview.total = new_charge - old_refund
// Old: 2 packs * $10 = $20
// New: 5 packs * $10 = $50
// preview.total = $50 - $20 = $30 (charge)
expect(preview.total).toBe(30);
// Old: 5 packs * $10 = $50
// New: 2 packs * $10 = $20
// preview.total = $20 - $50 = -$30 (credit)
expect(preview.total).toBe(-30);
Legacy: initTestScenario
For simpler cases without entities, initTestScenario is still available but initScenario is preferred:
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
const { customerId, autumnV1, ctx } = await initTestScenario({
customerId: "unique-test-id",
products: [free, addon],
attachProducts: [free.id], // Original IDs (auto-prefixed)
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
Manual Setup (when initScenario doesn't fit)
Customer Initialization
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
await initCustomerV3({
ctx,
customerId,
customerData: { fingerprint: "test" },
withTestClock: true,
withDefault: true, // Attach default product on creation
attachPm: "success",
});
Product Initialization (Direct)
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
await initProductsV0({
ctx,
products: [free, pro],
prefix: customerId, // Prefix product IDs for isolation
});
Running Tests
Run specific test block
Place cursor inside a test.concurrent() block and press Cmd+T.
Rerun last test
Cmd+Shift+P → "Rerun Last Task"
Run entire file
bun test path/to/file.test.ts
Code Style
Avoid Parameter Duplication
When calling similar methods (like previewUpdate + update), define params once and reuse:
// ❌ BAD - Duplicated params
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
});
// ✅ GOOD - Define once, reuse
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
await autumnV1.subscriptions.update(updateParams);