How We Ship Multiple Times a Day – and Sleep at Night

RyeCombinator1 pts0 comments

tech: How we ship multiple times a day - and sleep at night

Sunday, May 24, 2026

How we ship multiple times a day - and sleep at night

Engineering Culture · Pave

How We Ship Multiple Times a Day — and Sleep at Night

Five years of testing culture, 13,551 test cases, and a philosophy that changed how we think about deployment.

By the Pave Engineering Team<br>May 2026

Five years ago, when we started building Pave — a platform that helps gig workers understand, track, and optimize their earnings — we made a deliberate bet on testing culture. Not because a VP mandated it. Not because a consultant told us to. We did it because we were a small team moving fast, and we knew that the only way to keep moving fast sustainably was to build a system we could trust completely.

Today, we push to production seven or eight times a day on average — sometimes more. Thirty days of commit history shows 376 merges to main. Every single one triggered an automatic deployment to production. No release windows. No "code freeze Thursdays." No staged rollout ceremonies. Just: tests pass, deploy.

376

Merges in 30 days

13,551

Individual test cases

Push to production

1,023

Spec files

We Never Drew a Line Between Unit and Integration Tests

Most engineering teams have a test pyramid: unit tests at the base, integration tests in the middle, end-to-end tests at the top. The taxonomy is tidy. The problem is that the taxonomy creates a false sense of permission — "that's an integration concern, we'll cover it at the integration layer" — and integration layers have a way of not getting built.

We skipped the taxonomy entirely. Our philosophy: a test is only meaningful if it exercises the full contract of the code under test. That includes the HTTP layer, the database, and the side effects.

We call all of our tests "unit tests." A test for our user signup endpoint doesn't just assert the HTTP response code. It fires a real POST request, then opens up the database.

What that looks like in practice — a single test for our user signup endpoint fires a real POST, then verifies five database tables and two async workers:

api/v1/users_controller_spec.rb

RSpec.describe Api::V1::UsersController, type: :request do<br>before do<br>expect(HbCheckinWorker).to receive(:perform_async).with(USER_SIGN_UP)<br>expect(BrazeWorkers::Signup).to receive(:perform_async)<br>post '/api/v1/users/create', params: { email:, password:, phone:, city: ... }<br>end

it 'returns http success and provisions all associated records' do<br>expect(response).to have_http_status(:success)<br>user = User.find_by(email: test_email)<br>expect(user.wallet.present?).to be_truthy<br>expect(user.credit.present?).to be_truthy<br>expect(user.linkage_setting.present?).to be_truthy<br>expect(user.user_setting.show_review).to be_truthy<br>expect(user.linkage_setting.lymo_platforms).to eq([<br>'uber', 'ubereats', 'doordash', 'lyft', 'grubhub', ...<br>])<br>end<br>end

One test. One POST. Five database tables verified. Two async workers asserted. That is the standard we hold ourselves to.

A Real Example: Plaid Webhooks

Pave integrates deeply with Plaid for bank transaction syncing. When Plaid sends a webhook — notifying us that new transactions are available — a lot needs to happen correctly: the webhook signature must be verified, the right background job must be enqueued, and an audit record must be written. If the bank connection has degraded to an error state, no job should fire at all.

plaid_webhooks_spec.rb

describe 'TRANSACTIONS/SYNC_UPDATES_AVAILABLE' do<br>let!(:plaid_item) { create(:plaid_item) }

it 'enqueues PlaidTransactionSyncWorker for the item' do<br>expect { post_webhook(payload) }<br>.to change(PlaidTransactionSyncWorker.jobs, :size).by(1)<br>end

it 'logs the event to PlaidEvent' do<br>expect { post_webhook(payload) }.to change(PlaidEvent, :count).by(1)<br>event = PlaidEvent.last<br>expect(event.webhook_code).to eq('SYNC_UPDATES_AVAILABLE')<br>end

context 'when the item is in error state' do<br>let!(:error_item) { create(:plaid_item, :error) }

it 'returns 200 but does not enqueue a job' do<br>expect { post_webhook(payload) }<br>.not_to change(PlaidTransactionSyncWorker.jobs, :size)<br>end<br>end<br>end

One test file covers the HTTP layer, the job queue, the audit log, and the conditional branching. There is no separate "integration test" for this flow. This is the unit test.

The ITEM/ERROR webhook tests go further — they assert that a specific database field transitions to login_required, and that we deliberately don't fire a Honeybadger notification, because this is an expected user-state transition, not an engineering error:

plaid_webhooks_spec.rb — ITEM/ERROR

it 'marks the item as login_required' do<br>post_webhook(payload)<br>expect(plaid_item.reload.status).to eq(PlaidItem::STATUS_LOGIN_REQUIRED)<br>end

it 'does NOT notify Honeybadger (expected user-state transition)' do<br>post_webhook(payload)<br>expect(Honeybadger).not_to have_received(:notify)<br>end

That second assertion is a business rule encoded directly into the test suite. Future engineers...

expect test user tests integration error

Related Articles