Skip to content
Joey Wang
Menu

Search

Full Stack Development

Building a Test Suite: Pyramid, Layers, and What to Skip

A framework for deciding how much to test: the testing pyramid, layered test responsibility by architecture tier, and what makes test data good.

· 4 min read

engineering #testing#rails#javascript#debugging

Audio summary

A test suite that tries to cover everything is not the same as a good test suite. The real questions are how much testing is enough, whether tests should mirror real usage or hunt for every edge case, and which layer, controller, service, or model, owns which assertion.

Real usage vs. total coverage

A test suite earns its keep by testing the right things, not by testing everything. Past a point, more coverage buys diminishing returns: fragile, slow pipelines and edge cases that never occur in production, just noise around the tests that matter.

The better order is to start from real usage flows, add coverage for the edge cases and regressions you’ve actually hit, and use risk to decide where the remaining effort goes, not exhaustiveness.

The testing pyramid

Test typeSpeedCostValueVolume
UnitFastLowLocal correctnessMany
IntegrationMediumMediumCross-component behaviorSome
End-to-end / UISlowHighUser-facing flowVery few

Keep unit tests abundant and fast, use integration tests where components actually have to cooperate, and reserve end-to-end tests for the paths that matter most to the user.

What makes a test good

Purposeful test data matters, bending inputs to exercise a specific path is legitimate, but that’s different from an unrealistic or over-rigid setup that only exists to make the test pass. A test that’s worth keeping asserts behavior rather than implementation, covers the happy path plus the failure and edge cases that are actually plausible, uses parameterization instead of copy-pasted blocks, and is named so that a failure tells you what broke without opening the file.

Do thisNot this
Test from the user’s perspectiveTest internal mechanics only
Purposeful, varied test dataRandom or unclear inputs
Parameterized coverageCopy-pasted test blocks
Names that describe intenttest_1, test_ABC
Assert behavior and outcomesAssert internal state

Where each layer’s logic should be tested

In a layered architecture it’s easy to end up re-testing the same logic at every level. The rule that keeps that from happening: test logic where it lives, not everywhere it’s used.

LayerResponsibilityShould test
ModelBusiness rules, data integrityValidations, scopes, logic methods
ServiceOrchestrates business flowUse cases, side effects
ControllerEntry point, contract with clientsRouting, response codes, request validation

Whether a controller test should mock its service depends on whether that service logic is already tested elsewhere. If it is, mock it and keep the controller test fast and isolated. If it isn’t, a mocked controller test is verifying a contract against code nobody has actually tested, which is worse than no test at all.

Examples across layers

Unit test (Python, pytest):

# order_model.py
class Order:
    def __init__(self, items):
        self.items = items

    def total_price(self):
        return sum(item['price'] * item['qty'] for item in self.items)

# test_order_model.py
import pytest
from order_model import Order

def test_total_price():
    items = [{'price': 10, 'qty': 2}, {'price': 5, 'qty': 4}]
    order = Order(items)
    assert order.total_price() == 10*2 + 5*4

Service-level integration test (Node.js, Mocha + Chai):

// service/orderService.js
function createOrder(userId, items) {
  if (!items.length) throw new Error('Cart is empty');
  return { userId, items, status: 'created' };
}

// test/orderService.test.js
const { expect } = require('chai');
const { createOrder } = require('../service/orderService');

describe('Order Service', () => {
  it('should create an order successfully', () => {
    const result = createOrder(1, [{ id: 1, qty: 2 }]);
    expect(result.status).to.equal('created');
  });

  it('should throw error for empty cart', () => {
    expect(() => createOrder(1, [])).to.throw('Cart is empty');
  });
});

Controller test (Rails), mocking the service it depends on:

# orders_controller.rb
class OrdersController < ApplicationController
  def create
    order = OrderService.new.create_order(params[:user_id], params[:items])
    render json: order, status: :created
  rescue => e
    render json: { error: e.message }, status: :unprocessable_entity
  end
end

# spec/controllers/orders_controller_spec.rb
RSpec.describe OrdersController, type: :controller do
  let(:service) { instance_double(OrderService) }

  before do
    allow(OrderService).to receive(:new).and_return(service)
  end

  it "returns 201 when order created" do
    allow(service).to receive(:create_order).and_return({ id: 123, status: "created" })
    post :create, params: { user_id: 1, items: [{ id: 1, qty: 2 }] }
    expect(response).to have_http_status(:created)
  end

  it "returns 422 when order fails" do
    allow(service).to receive(:create_order).and_raise("Cart is empty")
    post :create, params: { user_id: 1, items: [] }
    expect(response).to have_http_status(:unprocessable_entity)
  end
end

End-to-end test (Cypress), for the one flow that has to work:

// cypress/e2e/order_flow.cy.js
describe('Order Checkout Flow', () => {
  it('should complete checkout', () => {
    cy.visit('/shop');
    cy.get('[data-cy=add-to-cart]').click();
    cy.get('[data-cy=checkout]').click();
    cy.get('[data-cy=confirm-order]').click();
    cy.contains('Order Confirmed').should('exist');
  });
});

The principle

None of this requires a framework beyond the pyramid and the layer table above: cover the real usage paths first, put each assertion at the layer where the logic actually lives, and use factories and parameterized tests to keep the suite from rotting into copy-paste. Everything past that, coverage dashboards, tagging slow tests for selective CI runs, is worth doing once the fundamentals hold, not before.