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