← cd ../writing

· 4 min read

A branch you can always ship: QA as a merge gate

On a regulated fintech app, no pull request merged until QA approved it. QA got small batches of work, and the development branch stayed deployable.

  • QA
  • Code review
  • Git workflow
  • iOS

Most teams test at the end. Engineers open pull requests, review each other’s code and merge into the development branch all sprint long. Then the changes go to a staging environment, and QA tests a build with weeks of work in it.

On a regulated fintech app where I led iOS, we started with classic staging testing too. Then we turned it around: QA became part of every pull request, and a pull request could not be merged until QA had approved it. It’s a small change to the workflow with two big effects. QA got small batches of work instead of large passes, and the development branch was always stable and deployable.

The problem with testing at the end

When QA only sees what has already been merged, three things go wrong.

Work arrives in waves. QA is quiet at the start of a sprint and overloaded at the end, with every feature landing at once. The release date depends on how that one big pass goes.

Bugs are found late. By the time QA reports a problem, the engineer who wrote the code has moved on to something else. Fixing it means rebuilding the context they had a week ago, and the fix lands in a branch that has changed underneath it.

Nobody knows if the branch is good. The development branch is a mix of reviewed-but-untested changes. When something breaks, it’s hard to tell which of twenty merged pull requests caused it, and “can we release today?” has no quick answer.

The flow: QA as a merge gate

Every pull request went through the same four steps:

  1. Open the pull request.
  2. Engineering review. Another engineer reviews the code, comments are resolved, and the pull request is approved.
  3. QA review. Once engineering has approved, QA is requested and does a QA pass on the change.
  4. The outcome. If QA approves, the pull request can be merged. If QA requests changes, the pull request goes back to the start.
  1. 01 · open
    Pull request openedOne feature or fix, small enough to test on its own
  2. 02 · engineering review
    Code review and approvalThen the developer runs a Fastlane lane that uploads a build to Firebase
  3. 03 · qa review
    QA pass on the buildRequested from the QA reviewer group once engineering approves
04a · qa approvesMerge unlockedThe QA check passes and the PR can merge. The main branch stays deployable.
04b · qa requests changesBack to the start ↺The fix goes through engineering review and QA again.
// nothing merges until QA approves

The order matters. QA only sees code that has already passed engineering review, so their time isn’t spent testing something that will change after a review comment. And when QA requests changes, the fix goes through engineering review and QA again, so nothing slips in untested on the way back.

A build for every approved pull request

QA needs a build of exactly the code under review, and getting one has to be cheap. Once engineering had approved a pull request, the developer triggered a Fastlane lane for it: one command that built the app with a timestamped build number, deployed against a test environment, and uploaded it to Firebase App Distribution. The lane then posted a comment on the pull request with a link to the build. QA opened the pull request, tapped the link and installed exactly the code they were asked to review. Because the build came after engineering approval, QA never installed something that was about to change.

Enforced in tooling, not by agreement

QA was a reviewer group on the repository, and a QA-approval status check blocked the merge until someone from that group had approved. There was no “I’ll merge now and QA can look later”. A rule that only lives in a team agreement gets skipped under deadline pressure. A required check doesn’t.

What changes

QA works in small batches. Instead of one large pass at the end, QA tests one change at a time, as it’s ready. The work is spread across the sprint, and each pass is small enough to do properly.

The development branch is always stable and deployable. Everything on it has been reviewed and tested. A release stops being a phase the team has to get through and becomes a decision: the branch is ready whenever you want it to be.

Bugs come back while the context is fresh. A problem found in a pull request goes back to the engineer who is still working on it. That’s the cheapest moment to fix anything.

The team trusts the codebase. This was the biggest difference for us. Because nothing reached the development branch without QA’s approval, the team was always confident in what was there, whether they were building on top of it or shipping it.

What it doesn’t change, and what it costs

You still need a regression pass. Testing each change on its own catches problems in that change. It doesn’t catch every interaction between changes. Every release still had a regression pass, designed by the QA team. It just stopped being the first time anyone looked at the work.

Pull requests wait for QA. They do, and that’s the obvious cost. In our case it never turned into a bottleneck, because the QA team was proactive and fast at picking up pull requests. That’s the real requirement: QA has to be part of the team’s daily flow, not a department at the end of it.

Bigger features need splitting. A QA pass per pull request is only small if the pull request is. Features that spanned several pull requests were either stacked on top of each other or handled individually as Jira subtasks, so each piece could be reviewed and tested on its own.

Give it a go

If your team tests at the end today, you don’t have to change everything at once. Start with a build for every approved pull request, posted where QA can find it. Then add QA as a required approval on the development branch, and keep pull requests small.

Give it a go. You’ll be surprised how much safer shipping feels when every change on the branch has already been tested.