Engineering Atlas › Foundations

Git, Testing and Code Review Basics

17 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Git stores snapshots

A commit is a snapshot of every file in the project at one moment, plus a message, an author, a timestamp and a pointer to its parent commit (or two parents, for a merge). Git names each commit by a hash of all of that, such as 9b1f411 (Pro Git, Git Objects). Change one byte anywhere, in a file or in a parent, and the hash changes. History is therefore a chain of commits where each one vouches for everything before it.

You work in three places: the working directory (files you edit), the staging area or index (what the next commit will contain, filled by git add), and the repository (commits, created by git commit). git status shows the difference between them; git diff shows the lines that changed.

Branches, merges and pull requests

A branch is a movable name for a commit. git switch -c feature creates a name pointing at the current commit; each new commit moves it forward. Meanwhile main moves forward with other people's work. To combine them, Git finds the merge base, the most recent commit both branches share, and compares each side's changes against it. Changes to different places combine automatically. Changes to the same lines in different ways are a conflict that a human must resolve. A rebase replays your commits on top of the latest main instead, giving a straight history at the cost of rewriting your commits' hashes, so only rebase branches nobody else has pulled.

Teams rarely merge straight into main. You push a branch and open a pull request (PR), continuous integration (CI) runs the tests, a reviewer reads the change, and only then is it merged. Branch strategy is a team choice too: Software Engineering at Google (Ch 16, Version Control and Branch Management) explains why Google keeps one trunk, and Pro Git's branching chapters show the workflow most other teams use.

What a test is

A test sets up a situation, runs the code, and asserts the result: "given a cart with two items at 10 and 15, the total is 25". A good test fails for one clear reason, runs fast, gives the same answer every time, and reads like a description of behaviour. Tests that depend on the current time, random numbers, test order or a shared database are flaky: they fail sometimes without a code change, and teams soon learn to ignore them. Inject the clock and the random source as parameters, as every practice exercise in this course does.

The test pyramid

The shape comes from Mike Cohn's Succeeding with Agile: lots of small, fast unit tests, some coarser service tests, very few tests through the UI. Fowler adds a working rule: if a higher-level test finds a bug and no lower-level test fails, write the lower-level test too (Fowler). An inverted pyramid, mostly end-to-end tests, is the "ice-cream cone" he warns against. Google's testing blog makes the same argument for its own teams (Google Testing Blog); the Software Engineering at Google book covers sizing tests by resources used, not by name (Ch 11 Testing Overview, Ch 14 Larger Testing).

LevelWhat it exercisesTypical speedRough share
UnitOne function or class, in memory, no I/OMicroseconds to millisecondsMost tests
IntegrationYour code plus one real dependency: database, queue, HTTP client, file systemTens of ms to secondsA solid layer
End-to-endThe deployed system through its real interfaceSeconds to minutesA handful

Unit test pure logic and edge cases: pricing rules, validation, parsing, state transitions, retry and backoff calculations, permission checks. These are cheap to write and pin down exactly what the code decides.

Integration test the boundaries: SQL queries and migrations against a real database (often in a container), unique constraints and transactions, JSON serialisation of API responses, HTTP handlers with their routing and middleware, message encoding for a queue, and configuration loading. Mocking a database in a unit test proves only that your code calls the mock the way you expected; the real database may still reject the query.

Keep end-to-end tests for the few journeys whose failure would be an incident: sign up, log in, pay. They are slow and brittle, so each one must earn its place.

Code review

A reviewer checks, roughly in this order: does the change do what the PR says, is it correct on edge cases and failures, are there tests that would catch a regression, is it secure (input validation, authorisation, secrets), will it be operable (logging, metrics, a safe rollout), and is it readable. Style questions that a formatter or linter can settle should be left to the tools. Comments should be specific and explain why: "this retries a non-idempotent POST, so a timeout could charge twice" is useful; "I don't like this" is not. Google's reviewer guide treats about 100 lines as a reasonable change and 1,000 as usually too large, and counts files too: 200 lines in one file may be fine, spread across 50 files probably is not (Google eng-practices). Authors should also expect speed from reviewers: Google's rule is a first response within one business day at most (Google eng-practices), because a slow review pushes authors to batch work into bigger changes.

Reviewing code written by an agent

AI agents now write a large share of code, and their output looks confident whether it is right or not. Review it as you would a capable new teammate's work, and check these specifically:

Run the code yourself for anything risky. The agent's summary of what it did is a claim, not evidence.

The same shape everywhere

Git hashes, tests, CI and review are all "trust but verify": accept work quickly, but check it with an independent mechanism before it can do damage. The same shape appears in payments, where a daily reconciliation compares your ledger with the bank's, and in operations, where a canary release checks a new version on 1% of traffic before it reaches everyone.

Down the stack

Layer 1The contract

Git promises that a commit hash identifies exactly one snapshot and its full history: if two people have the same commit hash, they have the same files and the same past (barring a hash collision, which is why the SHA-256 transition exists). Branches promise nothing except "this name points at that commit now". A merge promises to apply both sides' changes relative to the merge base, and to stop and ask when they overlap. A test suite's contract is narrower than people assume: a green run means these specific assertions held on this code, in this environment, this time. It says nothing about paths no test exercised.

Layer 2The mechanism

Inside .git/objects, Git stores four kinds of object. A blob is a file's content. A tree is a directory listing that maps names to blob or tree hashes. A commit points to one tree plus parent commits, author and message. An annotated tag points to a commit. Each object's name is the SHA-1 hash of a header such as blob 12 followed by a zero byte and the content, and the object is stored zlib-compressed in a file named by the hash, with the first two hex characters as the directory (Pro Git, Git Objects). Since Git 2.13 that SHA-1 is a hardened implementation that resists the SHAttered collision attack, and a repository can opt in to SHA-256 with extensions.objectFormat; such repositories cannot be read by older Git versions, and the transition plan keeps SHA-1 and SHA-256 names convertible during migration (Git hash-function-transition). A branch is a small file such as .git/refs/heads/main holding a commit hash, and .git/HEAD says which branch you are on. Because identical content gives an identical hash, a file unchanged across 1,000 commits is stored once. git merge walks the commit graph to find the best common ancestor (git merge-base shows it), then does a three-way comparison of base, ours and theirs.

Layer 3The machine

Hashing is fast, hundreds of MB per second per core, but rehashing a large repository on every git status would still be slow. So the index file records each tracked file's stat data (ctime, mtime, device, inode, size and more), and git status calls stat on each file and only rehashes ones whose metadata changed (Git index format). Loose objects are later packed into packfiles that store similar objects as deltas against each other, which is why a repository with years of history is often smaller than you would expect. Test speed follows the latency numbers from the first lesson: a unit test runs in memory in microseconds; an integration test opens sockets, crosses process boundaries and waits for a database to fsync, so it costs milliseconds; an end-to-end test drives a browser over the network and costs seconds.

Why it's built this way

  1. Many people change one codebase at once, so history must be shared without trusting every copy.
  2. Content hashes make tampering and corruption detectable and make identical content free to store, so Git names everything by hash.
  3. Snapshots plus a common ancestor are enough to merge two lines of work automatically most of the time.
  4. Checking correctness by hand does not scale, so we encode expectations as tests; fast tests run on every change, so most tests must be fast and in memory.
  5. Tests only check what someone thought of, so a human reviewer adds judgement about intent, risk and design that automation cannot.

Diagram

flowchart LR
  E["Edit on a branch"] --> C["Commit: snapshot plus parent hash"]
  C --> P["Push and open pull request"]
  P --> U["CI: unit tests, seconds"]
  U --> I["CI: integration tests, minutes"]
  I --> R["Human review"]
  R -->|changes requested| E
  R -->|approved| M["Merge into main"]
  M --> D["Deploy with canary"]

Worked example

An agent opens a PR adding discount codes to checkout: 420 changed lines across 9 files, with the summary "Added discount codes, all tests pass." CI agrees: 1,180 unit tests in 25 seconds and 140 integration tests against a Postgres container in 3 minutes, all green.

The reviewer starts with the diff of the test files, not the code. Three things stand out. First, an existing test test_total_rounds_half_up now expects 10.00 instead of 10.01; the agent changed the rounding rule and then updated the test to match. Second, the new discount logic has eight unit tests, but none for a code that has expired or one applied twice. Third, there is no integration test for the new discount_redemptions table, which has a unique constraint on (order_id, code) that the code relies on to stop double use.

Then the code. Discounts are computed with floating-point numbers, so 3 items at 19.99 with 15% off gives 50.974499999... and rounds the wrong way about once in a few thousand orders. Two files were reformatted, adding 160 lines of noise.

The review asks for: reverting the rounding change and the reformatting; money in integer cents; unit tests for expiry, a zero total and double application; and one integration test that inserts the same redemption twice and asserts the second fails. The revised PR is 230 lines, CI adds 40 seconds, and the double-use bug, which would have let one code be redeemed by two concurrent checkouts, never reaches production. Total review time: about 40 minutes, against an incident that would have cost a day and real money.

In the wild

Trade-offs

ChoiceStrengthsWeaknessesChoose when
Unit testsFast, precise, easy to debugMiss integration bugs, can over-mockLogic, calculations, edge cases
Integration testsCatch SQL, schema, wire format and config bugsSlower, need real dependenciesEvery boundary your code owns
End-to-end testsTest what users seeSlow, flaky, hard to diagnoseA few critical journeys
Merge commitsPreserve true history, no rewritingBusier graphShared branches, long-lived work
Rebase and fast-forwardLinear, readable historyRewrites hashes, risky on shared branchesYour own short-lived branches
Small PRsFaster, deeper reviews, easy rollbackMore PRs to coordinateAlmost always

Pitfalls in production

Leadership lens

Check yourself

  1. Why does changing an old commit's message change the hash of every commit after it?

    Show answer

    Each commit's hash covers its parent's hash, so a new hash for the old commit changes the parent field of its child, which changes the child's hash, and so on to the tip.

  2. A service's repository code builds an SQL query and the unit test mocks the database. What kind of bug can that test never catch?

    Show answer

    Any bug in how the real database treats the query: a syntax error, a wrong column name, a missing index, a violated constraint or a transaction behaving differently. Only an integration test against a real database catches those.

  3. An agent's PR changes an expected value in an existing test. What should a reviewer do?

    Show answer

    Treat it as a behaviour change and ask why: either the old expectation was wrong and the PR should say so, or the new code broke something and the test was bent to hide it.

  4. Why is a suite with 10,000 end-to-end tests and no unit tests a poor design?

    Show answer

    It is slow, so it runs rarely; flaky, so failures get ignored; and hard to diagnose, because a failure could be anywhere in the stack. Most checks belong at the unit level, where they are fast and precise.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →