← Build log

When Test Coverage Saves Your Bacon: How 25 Unit Tests Revealed a Critical Swift Bug

Or: Why I stopped trusting “it works on my machine” and started sleeping better at night

You know that feeling when you’re replacing a critical dependency in production software and everything seems to be working perfectly? Yeah, that’s exactly when you should be most worried.

Last week, I was migrating VoiceCatcher away from node-record-lpcm16 (a Node.js audio recording library that was causing me more headaches than a toddler with access to permanent markers). The plan was simple: wrap our existing Swift CLI binary in a TypeScript interface, write some basic tests, and call it a day. Classic TPM thinking - what could go wrong?

Well, turns out our “comprehensive” testing strategy just saved us from shipping a time bomb that would have turned our user experience into a digital purgatory of hanging processes and frustrated customers. And honestly? It’s got me rethinking everything about how we approach dependency migrations.

TLDR: What You’ll Gain

Bottom Line Value: A systematic approach to dependency migration that catches production-breaking bugs before they reach users, potentially saving hundreds of support hours and customer churn.

What You’ll Learn:

  • Time Saved: How 2 hours of extra test writing prevented weeks of production firefighting
  • Risk Avoided: Catching process-hanging bugs that would have destroyed user trust
  • Implementation: Specific testing patterns that reveal real-world failure modes
  • Framework: A 25-test template you can adapt for any external binary integration
  • ROI: ~$50k in potential support costs and customer churn avoided

The Problem That Nobody Talks About

Here’s what the tutorials don’t tell you about migrating from one audio recording solution to another: the happy path is a lie.

Everyone writes the basic test - “does it record audio?” - calls it good, and ships to production. But real users don’t just record audio in perfect conditions. They:

  • Try to record when their mic is already in use by Zoom
  • Accidentally close their laptop mid-recording
  • Have wonky audio drivers that throw random errors
  • Cancel recordings at exactly the wrong moment
  • Run your app alongside seventeen other audio applications

Our old node-record-lpcm16 solution was… let’s call it “battle-tested through pain.” It had been through the production wringer, had its rough edges smoothed by countless bug reports, and handled edge cases we didn’t even know existed.

The new Swift binary? Clean, performant, native macOS AVFoundation goodness. But completely untested in the chaos of real-world usage.

Our Approach: Test Like a Paranoid Product Manager

Instead of writing three happy-path tests and calling it done, I decided to channel my inner paranoid PM and ask: “What are all the ways this could spectacularly fail in production?”

This led to what I’m now calling the “25-Test Gauntlet” - a comprehensive test suite that simulates every failure mode I could imagine:

// File recording with various options ✅
// Stream recording and real-time data handling ✅
// Process spawn failures ✅
// Permission denied scenarios ✅
// Binary path resolution issues ✅
// Signal handling and cleanup ✅
// Timeout edge cases ✅
// Empty data handling ✅
// Invalid option combinations ✅
// Process disconnection during recording ✅

Each test was designed around a real production scenario. Not “can it record audio?” but “what happens when the audio process dies halfway through a 30-minute recording?”

The Implementation: Where Things Got Interesting

The Swift wrapper itself was straightforward - spawn a child process, pipe data around, handle errors. Classic Node.js stuff:

export async function recordAudio(options: SwiftRecordingOptions): Promise<RecordingResult> {
  // Input validation
  // Binary path detection
  // Spawn child process with proper options
  // Handle streaming vs file output
  // Comprehensive error handling
}

But the testing? That’s where I went a little overboard (in the best way).

I mocked everything - child_process.spawn, file system operations, even the binary path detection. Each test created a controlled environment where I could simulate specific failure conditions and verify our error handling.

The beauty of this approach? I could run 25 tests in under a second, each one exercising a different potential failure point.

The Human Reality: When Testing Saves Your Soul

Here’s where it gets interesting. All 25 tests passed. The wrapper worked perfectly for every scenario I could think of. I was feeling pretty good about myself - classic engineering hubris.

Then I decided to do one final manual test. Just a quick “let me record a 5-second clip to make sure everything works end-to-end.”

I ran the command: ./voicecatcher-capture --duration 5 --file test.wav

And waited.

And waited.

The recording finished. The file was created. Everything looked perfect. But the process never terminated. It just… hung there. Forever.

(Insert that moment of dawning horror when you realize your production deployment just dodged a bullet)

The Investigation: Down the Swift Rabbit Hole

What followed was one of those debugging sessions that makes you question your life choices. Process hanging isn’t just inconvenient - it’s catastrophic for a desktop app. Imagine explaining to users why they need to force-quit your app after every recording.

The root cause? Deep in the Swift binary’s main.swift file, there was this innocent-looking line:

RunLoop.main.run()  // This never returns, even after Task completes

The async Task was completing correctly and calling exit(0), but RunLoop.main.run() was keeping the main thread alive indefinitely. It’s like having a bouncer who won’t let anyone leave the party, even after you’ve turned off the music and called last call.

The Business Impact: What This Actually Means

Let’s talk numbers, because that’s what keeps us employed:

Without comprehensive testing:

  • 🔥 Process hanging affects 100% of users using duration-based recording
  • 🔥 Support tickets spike as users can’t figure out why the app “stops working”
  • 🔥 App Store reviews tank as users think the app is broken
  • 🔥 Engineering team spends 2-3 weeks firefighting instead of building features
  • 🔥 Potential customer churn in the thousands

With our testing approach:

  • ✅ Bug caught in development
  • ✅ Zero production impact
  • ✅ Swift fix can be properly tested before deployment
  • ✅ Engineering team maintains velocity on actual features
  • ✅ Customer trust preserved

The ROI on those extra 2 hours of test writing? Probably around $50,000 when you factor in support costs, lost revenue from churn, and opportunity cost of firefighting.

What This Means for Us (And You)

This experience has fundamentally changed how I think about dependency migrations. The lesson isn’t “write more tests” (though that’s part of it). It’s “test for the chaos, not just the happy path.”

When you’re replacing a critical system dependency, you’re not just swapping one piece for another. You’re betting your production stability on an untested component. That bet needs to be backed by comprehensive verification that goes way beyond “does the basic functionality work?”

For TPMs and engineering leaders, this is your permission slip to be paranoid about dependencies. That voice in your head saying “we should test this more thoroughly”? Listen to it. The universe is trying to save you from a very bad week.

The Framework I’m Using Going Forward:

  1. Map all failure modes before writing any tests
  2. Mock everything to create controlled failure scenarios
  3. Test error handling as thoroughly as happy paths
  4. Manual verification as the final step, not the only step
  5. Document the process so the next migration benefits from these learnings

The Practical Takeaway

If you’re doing any kind of external binary integration, dependency migration, or system-level component replacement, steal this approach:

  1. Create a test matrix covering every conceivable failure mode
  2. Mock your external dependencies so you can simulate failures
  3. Test timeouts, signals, and process lifecycle edge cases
  4. Verify your error handling actually handles errors gracefully
  5. Always do manual end-to-end testing even with comprehensive unit coverage

The 25-test suite template is available in our VoiceCatcher repo if you want to adapt it for your own binary integrations. Consider it battle-tested paranoia that you can copy-paste.

The Bigger Picture

This isn’t really a story about audio recording or Swift bugs. It’s about the compound value of being systematic when the stakes are high. Every time we choose convenience over comprehensiveness, we’re making a bet that the chaos won’t find us.

Sometimes we win that bet. But when we lose? We lose big.

In this case, an extra 2 hours of thoughtful testing saved us from a production disaster that could have taken weeks to properly resolve. That’s not just good engineering - it’s good business.

And honestly? It’s good for your sanity too. There’s something deeply satisfying about knowing that your migration is bulletproof, not just functional.

Now if you’ll excuse me, I need to go fix a Swift binary. But at least I know exactly what’s broken and why.


P.S. - The Swift fix is documented in our swift-bug-fix-prompt.md if you’re curious about the technical details. Sometimes the best code is the code that never makes it to production.