📝e2e-testing/07-destroywithalldeps.md
One-Line Cleanup: The destroyWithAllDeps Pattern
category: "E2E Testing"
One-Line Cleanup: The destroyWithAllDeps Pattern
The Problem
Test cleanup is often ignored because it's painful:
- Manual cleanup code is verbose
- Order matters (can't delete parent before children)
- Orphaned data causes test pollution
- Some tests skip cleanup "for speed"
Our solution: the inverse of withAllDeps.
Understanding Deletion Order
For RealWorld, deletion order matters:
For an article, cleanup usually means:
- delete comments first
- remove the article itself
- do not delete unrelated users just because they were part of setup
For a comment, cleanup usually means:
- delete the comment
- leave the article and user alone unless this test explicitly created and owns them
We only delete what we created, in the right order.
Implementing Destroy with AllDeps
Update PostBuilder with destroy methods:
export class PostBuilder {
// Track what we created for cleanup
private createdEntities = {
article: null as CreatedArticle | null,
author: null as CreatedUser | null,
};
async create(): Promise<{
article: CreatedArticle;
author: CreatedUser;
}> {
// ... existing create logic ...
// Track for cleanup
this.createdEntities.article = article;
this.createdEntities.author = author;
return { article, author };
}
// Destroy this specific article and its children
async destroyWithAllDeps(): Promise<void> {
// 1. Delete comments on this article
await this.deleteComments(this.createdEntities.article!.slug);
// 2. Unfavorite (don't delete users, just unfavorite)
await this.unfavoriteAll(this.createdEntities.article!.slug);
// 3. Delete the article itself
await this.delete(this.createdEntities.article!.slug);
// Note: We DON'T delete the author (it may be reused)
}
private async deleteComments(slug: string): Promise<void> {
const response = await this.request.get(`/api/articles/${slug}/comments`);
if (response.ok()) {
const { comments } = await response.json();
for (const comment of comments) {
await this.request.delete(
`/api/articles/${slug}/comments/${comment.id}`
);
}
}
}
private async unfavoriteAll(slug: string): Promise<void> {
// Get who favorited and unfavorite (we don't delete users)
const response = await this.request.get(`/api/articles/${slug}`);
if (response.ok()) {
const { article } = await response.json();
// Just log - we don't delete other users
}
}
// Static method for explicit cleanup
static async destroy(
request: APIRequestContext,
article: CreatedArticle,
author?: CreatedUser
): Promise<void> {
const builder = new PostBuilder(request, new TestDataHelper());
builder.createdEntities.article = article;
builder.createdEntities.author = author || null;
await builder.destroyWithAllDeps();
}
}Using Destroy with AllDeps
test('explicit cleanup when needed', async ({ request }) => {
const helper = new TestDataHelper('Post');
const builder = new PostBuilder(request, helper);
// Create
const { article } = await builder
.withTitle('Temp Article')
.withAllDeps()
.create();
// Do test stuff...
await verifyArticleExists(article.slug);
// Explicit cleanup
await builder.destroyWithAllDeps();
// Verify it's gone
const response = await request.get(`/api/articles/${article.slug}`);
expect(response.status()).toBe(404);
});Test Fixtures with Automatic Cleanup
The best approach - automatic cleanup via fixtures:
// fixtures.ts
export const test = base.extend({
testData: async ({ request }, use) => {
const factory = new TestDataFactory(request, `test-${Date.now()}`);
await use(factory);
// Cleanup AFTER test runs - runs automatically!
const summary = factory.getSummary();
console.log(`Cleaning up: ${JSON.stringify(summary)}`);
await factory.cleanup();
},
});
// cleanup.ts (in TestDataFactory)
async cleanup(): Promise<void> {
const errors: string[] = [];
// Delete in proper order (children first)
// 1. Delete all comments created
for (const comment of this.createdComments) {
try {
await this.comment().delete(comment.id);
} catch (e) {
// Ignore - might already be deleted
}
}
// 2. Delete all articles (this also removes favorites)
for (const article of this.createdArticles) {
try {
await new PostBuilder(this.request, this.helper)
.destroyWithAllDeps();
} catch (e) {
errors.push(`Article ${article.slug}: ${e}`);
}
}
// 3. Users - we typically DON'T delete users in RealWorld
// (they're shared, or can't be deleted)
if (errors.length > 0) {
console.warn('Cleanup had errors:', errors);
}
}The Cleanup Summary
test('see cleanup in action', async ({ testData }) => {
// Create complex data
await testData.post().withAllDeps().create();
await testData.post().withAllDeps().create();
await testData.user().withDefaults().create();
});
// After test runs, you see:
// Cleaning up: {"Article":2,"Comment":0,"User":0}What We've Learned
- Implemented destroyWithAllDeps for cascade cleanup
- Proper deletion order (children before parents)
- Only delete what we created
- Automatic cleanup via fixtures
Next: We'll expand seeding coverage with CommentBuilder and UserBuilder.
Previous: Builders as Seeding: Replace beforeEach Sprawl with One Line Next: Expanding Coverage: CommentBuilder & UserBuilder ->