Skip to content

Commit b911ded

Browse files
committed
docs(scenario): improve best practices with ctx.previous examples
- Add "Split Scenarios for Better Organization" section with validation and CRUD examples showing when to split vs keep as single scenario - Update "Return Meaningful Values" with Create→Get flow demonstrating ctx.previous usage - Fix ctx.previous! assertions by using return value assertions instead (e.g., res.data<T>()! or rows.first()!) for cleaner consumer code
1 parent 61a3428 commit b911ded

1 file changed

Lines changed: 230 additions & 18 deletions

File tree

docs/scenario.md

Lines changed: 230 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,7 @@ export default scenario("User CRUD API", { tags: ["api", "integration"] })
589589
})
590590
.step("Get user", async (ctx) => {
591591
const { http } = ctx.resources;
592-
const { id } = ctx.previous!;
592+
const { id } = ctx.previous;
593593
const res = await http.get(`/users/${id}`);
594594
expect(res).toBeOk().toHaveStatus(200).toHaveDataMatching({
595595
id,
@@ -599,7 +599,7 @@ export default scenario("User CRUD API", { tags: ["api", "integration"] })
599599
})
600600
.step("Update user", async (ctx) => {
601601
const { http } = ctx.resources;
602-
const { id } = ctx.previous!;
602+
const { id } = ctx.previous;
603603
const res = await http.patch(`/users/${id}`, { name: "Bob" });
604604
expect(res).toBeOk().toHaveStatus(200).toHaveDataMatching({
605605
name: "Bob",
@@ -608,7 +608,7 @@ export default scenario("User CRUD API", { tags: ["api", "integration"] })
608608
})
609609
.step("Delete user", async (ctx) => {
610610
const { http } = ctx.resources;
611-
const { id } = ctx.previous!;
611+
const { id } = ctx.previous;
612612
const res = await http.delete(`/users/${id}`);
613613
expect(res).toBeOk().toHaveStatus(204);
614614
})
@@ -653,13 +653,13 @@ export default scenario("Database Transaction", { tags: ["db", "postgres"] })
653653
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
654654
["Alice", "alice@example.com"],
655655
);
656-
return insert.rows.first();
656+
return insert.rows.first()!;
657657
});
658658
return result;
659659
})
660660
.step("Verify user exists", async (ctx) => {
661661
const { pg } = ctx.resources;
662-
const { id } = ctx.previous!;
662+
const { id } = ctx.previous;
663663
const result = await pg.query<{ name: string }>(
664664
"SELECT name FROM users WHERE id = $1",
665665
[id],
@@ -757,7 +757,7 @@ export default scenario("Full Stack Test", {
757757
})
758758
.step("Verify in database", async (ctx) => {
759759
const { pg } = ctx.resources;
760-
const { id } = ctx.previous!;
760+
const { id } = ctx.previous;
761761
const result = await pg.query(
762762
"SELECT * FROM items WHERE id = $1",
763763
[id],
@@ -796,33 +796,52 @@ scenario("Email Test")
796796
Return data that subsequent steps need. This enables type-safe data flow through
797797
`ctx.previous`.
798798

799-
```typescript
800-
import { client, scenario } from "jsr:@probitas/probitas";
799+
Good - returns data needed by next step:
801800

802-
const data = { name: "Alice", email: "alice@example.com" };
801+
```typescript
802+
import { client, expect, scenario } from "jsr:@probitas/probitas";
803803

804-
// Good - returns data needed by next step
805-
scenario("Good Example")
804+
scenario("User creation and retrieval")
806805
.resource(
807806
"http",
808807
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
809808
)
810809
.step("Create user", async (ctx) => {
811-
const { http } = ctx.resources;
812-
const res = await http.post("/users", data);
813-
return res.data<{ id: number }>();
810+
const res = await ctx.resources.http.post("/users", {
811+
name: "Alice",
812+
email: "alice@example.com",
813+
});
814+
return res.data<{ id: number }>()!;
815+
})
816+
.step("Get created user", async (ctx) => {
817+
// ctx.previous is typed as { id: number }
818+
const res = await ctx.resources.http.get(`/users/${ctx.previous.id}`);
819+
expect(res).toHaveStatus(200).toHaveDataMatching({ name: "Alice" });
814820
})
815821
.build();
822+
```
816823

817-
// Avoid - loses useful data
818-
scenario("Avoid Example")
824+
Avoid - loses useful data:
825+
826+
```ts
827+
import { client, expect, scenario } from "jsr:@probitas/probitas";
828+
829+
scenario("User creation and retrieval")
819830
.resource(
820831
"http",
821832
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
822833
)
823834
.step("Create user", async (ctx) => {
824-
const { http } = ctx.resources;
825-
await http.post("/users", data);
835+
await ctx.resources.http.post("/users", {
836+
name: "Alice",
837+
email: "alice@example.com",
838+
});
839+
// No return value - next step can't access created user's ID!
840+
})
841+
.step("Get created user", async (ctx) => {
842+
// ctx.previous is undefined - we lost the user ID!
843+
const res = await ctx.resources.http.get("/users/???");
844+
expect(res).toHaveStatus(200);
826845
})
827846
.build();
828847
```
@@ -906,3 +925,196 @@ scenario("Avoid Setup Example")
906925
})
907926
.build();
908927
```
928+
929+
### Split Scenarios for Better Organization
930+
931+
Export multiple focused scenarios from a single file rather than one large
932+
scenario. This provides several benefits:
933+
934+
- **Parallel execution**: Separate scenarios can run concurrently, improving
935+
overall test speed
936+
- **Tag filtering**: Each scenario can have its own tags for fine-grained test
937+
selection
938+
- **Clear failure identification**: When a test fails, you immediately know
939+
which specific scenario failed
940+
941+
Good - multiple focused scenarios:
942+
943+
```typescript
944+
// user-validation.probitas.ts
945+
import { client, expect, scenario } from "jsr:@probitas/probitas";
946+
947+
export default [
948+
scenario("User validation - rejects empty name", {
949+
tags: ["api", "validation"],
950+
})
951+
.resource(
952+
"http",
953+
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
954+
)
955+
.step("Create user with empty name", async (ctx) => {
956+
const res = await ctx.resources.http.post("/users", { name: "" });
957+
expect(res).toHaveStatus(400);
958+
})
959+
.build(),
960+
961+
scenario("User validation - rejects duplicate email", {
962+
tags: ["api", "validation"],
963+
})
964+
.resource(
965+
"http",
966+
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
967+
)
968+
.setup(async (ctx) => {
969+
const res = await ctx.resources.http.post("/users", {
970+
name: "Alice",
971+
email: "alice@example.com",
972+
});
973+
const user = res.data<{ id: number }>();
974+
return async () => {
975+
await ctx.resources.http.delete(`/users/${user!.id}`);
976+
};
977+
})
978+
.step("Create user with duplicate email", async (ctx) => {
979+
const res = await ctx.resources.http.post("/users", {
980+
name: "Bob",
981+
email: "alice@example.com",
982+
});
983+
expect(res).toHaveStatus(409);
984+
})
985+
.build(),
986+
987+
scenario("User validation - rejects invalid email format", {
988+
tags: ["api", "validation"],
989+
})
990+
.resource(
991+
"http",
992+
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
993+
)
994+
.step("Create user with invalid email", async (ctx) => {
995+
const res = await ctx.resources.http.post("/users", {
996+
name: "Alice",
997+
email: "not-an-email",
998+
});
999+
expect(res).toHaveStatus(400);
1000+
})
1001+
.build(),
1002+
];
1003+
```
1004+
1005+
Avoid - one monolithic scenario:
1006+
1007+
```ts
1008+
// user-validation.probitas.ts
1009+
import { client, expect, scenario } from "jsr:@probitas/probitas";
1010+
1011+
export default scenario("User validation", { tags: ["api", "validation"] })
1012+
.resource(
1013+
"http",
1014+
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
1015+
)
1016+
.step("Reject empty name", async (ctx) => {
1017+
const res = await ctx.resources.http.post("/users", { name: "" });
1018+
expect(res).toHaveStatus(400);
1019+
})
1020+
.step("Reject duplicate email", async (ctx) => {
1021+
// Create first user
1022+
await ctx.resources.http.post("/users", {
1023+
name: "Alice",
1024+
email: "alice@example.com",
1025+
});
1026+
// Try to create another user with same email
1027+
const res = await ctx.resources.http.post("/users", {
1028+
name: "Bob",
1029+
email: "alice@example.com",
1030+
});
1031+
expect(res).toHaveStatus(409);
1032+
})
1033+
.step("Reject invalid email format", async (ctx) => {
1034+
const res = await ctx.resources.http.post("/users", {
1035+
name: "Alice",
1036+
email: "not-an-email",
1037+
});
1038+
expect(res).toHaveStatus(400);
1039+
})
1040+
.build();
1041+
```
1042+
1043+
However, when steps are part of a sequential workflow where each step depends on
1044+
the previous one, keep them in a single scenario. Use `ctx.previous` to pass
1045+
data between steps:
1046+
1047+
Good - sequential CRUD operations as a single scenario:
1048+
1049+
```ts
1050+
// user-crud.probitas.ts
1051+
import { client, expect, scenario } from "jsr:@probitas/probitas";
1052+
1053+
export default scenario("User CRUD workflow", { tags: ["api", "crud"] })
1054+
.resource(
1055+
"http",
1056+
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
1057+
)
1058+
.step("Create user", async (ctx) => {
1059+
const res = await ctx.resources.http.post("/users", {
1060+
name: "Alice",
1061+
email: "alice@example.com",
1062+
});
1063+
expect(res).toHaveStatus(201);
1064+
return res.data<{ id: number }>()!;
1065+
})
1066+
.step("Get user", async (ctx) => {
1067+
const res = await ctx.resources.http.get(`/users/${ctx.previous.id}`);
1068+
expect(res).toHaveStatus(200).toHaveDataMatching({ name: "Alice" });
1069+
return ctx.previous;
1070+
})
1071+
.step("Update user", async (ctx) => {
1072+
const res = await ctx.resources.http.patch(`/users/${ctx.previous.id}`, {
1073+
name: "Alice Smith",
1074+
});
1075+
expect(res).toHaveStatus(200);
1076+
return ctx.previous;
1077+
})
1078+
.step("Delete user", async (ctx) => {
1079+
const res = await ctx.resources.http.delete(`/users/${ctx.previous.id}`);
1080+
expect(res).toHaveStatus(204);
1081+
})
1082+
.build();
1083+
```
1084+
1085+
Avoid - splitting sequential workflow into separate scenarios:
1086+
1087+
```ts
1088+
// user-crud.probitas.ts - DON'T do this!
1089+
import { client, expect, scenario } from "jsr:@probitas/probitas";
1090+
1091+
// These scenarios cannot run in parallel - they share state!
1092+
export default [
1093+
scenario("User CRUD - create", { tags: ["api", "crud"] })
1094+
.resource(
1095+
"http",
1096+
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
1097+
)
1098+
.step("Create user", async (ctx) => {
1099+
const res = await ctx.resources.http.post("/users", {
1100+
name: "Alice",
1101+
email: "alice@example.com",
1102+
});
1103+
expect(res).toHaveStatus(201);
1104+
// Problem: How do we pass the user ID to the next scenario?
1105+
})
1106+
.build(),
1107+
1108+
scenario("User CRUD - get", { tags: ["api", "crud"] })
1109+
.resource(
1110+
"http",
1111+
() => client.http.createHttpClient({ url: "http://localhost:8080" }),
1112+
)
1113+
.step("Get user", async (ctx) => {
1114+
// Problem: We don't know the user ID from the previous scenario!
1115+
const res = await ctx.resources.http.get("/users/???");
1116+
expect(res).toHaveStatus(200);
1117+
})
1118+
.build(),
1119+
];
1120+
```

0 commit comments

Comments
 (0)