main*
📝e2e-testing/07-destroywithalldeps.md
📅November 30, 20244 min read

One-Line Cleanup: The destroyWithAllDeps Pattern

#e2e-testing#cleanup#cascade-delete#teardown

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 ->