You have been building PostKit for two weeks. Today you step back and look at what you actually built — not feature by feature, but as a system.
Understanding your app as a system is not just an academic exercise. It is what lets you answer two practical questions:
- If I change something, what else might break?
- Where do I need tests?
These questions have the same answer. The places where change propagates are exactly the places that need tests.
By the end of this lesson you should be able to:
- Define what a system boundary is and identify three types in PostKit
- Map the actual boundaries in your own app
- Distinguish between unit tests and integration tests
- Identify the behaviors in PostKit that are most important to test
- Explain what makes a test meaningful versus useless
A boundary is any point in your app where one piece of code hands something off to another.
Boundaries are where failures hide. Not because the individual pieces are broken — often each one works fine in isolation. Failures happen at boundaries because the pieces make assumptions about each other, and those assumptions are not always true.
Three types of boundaries in PostKit:
A library boundary is the point where your app calls a PostKit package.
// Your code ← boundary → Library code
const time = formatTime(readingTime(form.body))Your code hands form.body to readingTime. The library does something with it and returns a value. Your code uses that value.
At this boundary:
- What does the library expect? A non-empty string? A specific format?
- What does it return? A number? An object?
undefinedif the input is empty? - What happens if the input is
""ornull?
You do not control what happens inside the library. You only control what you send in and how you handle what comes out. That is the boundary.
A state boundary is the point where a component reads from or writes to a store.
// Component reads from the store
const posts = usePostStore(s => s.posts)
// Component writes to the store
const updatePost = usePostStore(s => s.updatePost)
updatePost(post.id, { title: form.title })At this boundary:
- What shape is the data the component expects? (
Post[]notPost | null) - What happens if the store returns an empty array instead of a populated one?
- If another component writes to the same store, does this component see the update?
Multiple components can share the same state boundary. A write in the editor affects what the list view reads. The boundary is the connection — and it goes in both directions.
A view boundary is the point where data becomes UI — where a component receives props and decides what to render.
// Data crosses into the component here
<PostCard post={post} />Inside PostCard, the component makes assumptions: post.title is a string, post.tags is an array, post.status is one of the valid values.
At this boundary:
- What does the component assume about its input?
- What happens if
post.tagsis empty? - What happens if
post.updatedAtisundefinedfor a new post?
View boundaries are where silent rendering failures happen. The component renders, nothing crashes, but something is blank or wrong.
The failures that are hardest to debug happen at boundaries. A value produced correctly in the store arrives malformed at the view. A library returns an unexpected type that silently corrupts a derived value. A route change leaves stale state behind.
This is why you map boundaries before you write tests. The map tells you where the risk is.
Work individually for 20 minutes, then compare with a partner for 15 minutes.
This is not a generic diagram. You are mapping your app — the actual files, the actual function calls, the actual data shapes you used.
Step 1 — Find your library boundaries (5 min)
Open your app. Search for every import from a PostKit package.
List each one:
- Which library is it?
- Which file calls it?
- What do you pass in?
- What do you do with the return value?
Example:
filterByStatus(posts, statusFilter) → Post[]
called in: PostListView.tsx inside useMemo
input: posts from store, statusFilter from UI store
output: filtered Post[] passed to sortByDate
Do this for every PostKit library call in your app.
Step 2 — Find your state boundaries (5 min)
List every place a component reads from or writes to a store.
Example:
usePostStore → posts: Post[]
read by: PostListView, PostCard, PostDetailView
written by: PostDetailView (updatePost, addPost, deletePost)
Identify any store that more than one component shares. That is a place where a write by one component affects what another component sees.
Step 3 — Find your view boundaries (5 min)
List the props each major component receives.
Example:
PostCard: post: Post
assumes: post.tags is string[], post.title is non-empty string
risk: post.tags might be [] if tags were not required
For each one, note: what would happen if a prop was undefined, empty, or the wrong shape?
Step 4 — Draw the connections (5 min)
On paper or a simple diagram tool, draw:
- A box for each store
- A box for each view and major component
- A box for each PostKit library you call
- Arrows between them labeled with what is being passed
Then mark:
- Which connections cross a library boundary? (your code → PostKit package)
- Which connections cross a state boundary? (store → component or component → store)
- Which connections cross a view boundary? (component → child component via props)
Step 5 — Partner review (15 min)
Swap maps with a partner. Without asking any questions, answer these from their diagram alone:
- Where does post data come from and where does it end up on screen?
- If the filter library returned
nullinstead of[]for no results, which component would break? - If
Post.tagschanged fromstring[]to{ name: string; color: string }[], how many places in their app would need to change?
Discuss what you found. If the diagram could not answer a question, that is a gap worth noting.
You have written unit tests for your library. A unit test calls one function with known input and checks that it returns the expected output:
test('filterByStatus returns only matching posts', () => {
const posts = [
{ id: '1', status: 'draft', title: 'A', ... },
{ id: '2', status: 'published', title: 'B', ... },
]
expect(filterByStatus(posts, 'draft')).toHaveLength(1)
})This runs fast. It is easy to write. It tells you exactly what broke when it fails.
But it only tests the library in isolation. It does not test anything about how the app uses the library.
Imagine your unit test passes — filterByStatus works correctly. Now imagine that in PostListView.tsx, you wrote:
// Bug: passing the wrong field
result = filterByStatus(result, tagFilter) // should be statusFilterYour unit test would still pass. The library works fine. The bug is in your app code, at the boundary between the UI and the library — and the unit test cannot see that.
This is the gap: unit tests verify that pieces work, not that pieces work together.
An integration test renders a real component, simulates a real user interaction, and checks what actually appears on screen. It crosses boundaries — state, libraries, components — all at once.
Here is what the setup looks like. You need three tools:
import { render, screen, fireEvent } from '@testing-library/react'render— mounts a component into a simulated browser environmentscreen— lets you query what is visible on screen (by text, role, label, etc.)fireEvent— simulates user interactions (clicks, typing, etc.)
These come from React Testing Library, which is designed to test components the way a user would interact with them — not by inspecting implementation details, but by checking what is actually visible.
Here is an integration test for the filter behavior in PostListView:
test('filtering by draft shows only draft posts', () => {
// Step 1: Put known data into the store
usePostStore.setState({
posts: [
makePost({ title: 'Draft post', status: 'draft' }),
makePost({ title: 'Published post', status: 'published' }),
]
})
// Step 2: Render the component
render(<PostListView />)
// Step 3: Simulate clicking the Draft filter button
fireEvent.click(screen.getByRole('button', { name: 'Draft' }))
// Step 4: Check what is now on screen
expect(screen.getByText('Draft post')).toBeInTheDocument()
expect(screen.queryByText('Published post')).not.toBeInTheDocument()
})What each step is doing:
usePostStore.setState(...) — Seeds the store with known data so the test is predictable. Without this, the test depends on whatever happened to be in the store, which makes it unreliable.
render(<PostListView />) — Mounts the component. The component reads from the store, runs its useMemo, and renders the initial list — both posts visible.
fireEvent.click(...) — Simulates clicking the Draft button. This updates the store's statusFilter, which triggers a re-render, which re-runs the useMemo, which calls filterByStatus.
screen.getByText(...) / screen.queryByText(...) — Queries what is now on screen. getByText throws if the element is not found. queryByText returns null instead of throwing — useful for asserting something is not there.
A single test click crosses all three boundary types from Part 1:
- View boundary — the button click updates UI state
- State boundary — the status filter is written to the store and read by
useMemo - Library boundary —
filterByStatusis called with the store data
If any one of those connections is broken, the test fails. It does not tell you exactly which step failed — but it tells you the behavior is broken, which is the first thing you need to know.
| What broke | Unit test catches it? | Integration test catches it? |
|---|---|---|
filterByStatus returns wrong results |
Yes | Yes |
Component passes wrong field to filterByStatus |
No | Yes |
| Store returns stale data after update | No | Yes |
Library returns null instead of [] |
Only if you test that case | Yes |
| Route change leaves previous filter active | No | Yes |
Unit tests are fast and precise — good for testing your library's logic. Integration tests are slower but catch the failures that happen at boundaries, which is where most app bugs live.
For PostKit, integration tests are more valuable — because the complexity is not inside any single library or component. It is in how they connect.
Not everything needs a test. The goal is to identify the behaviors that are:
- Load-bearing — if this breaks, the app does not work
- Easy to break accidentally — changes to nearby code could affect this
- Hard to verify manually — you cannot just look and tell if it is right
The acceptance criteria from PostKit.md are your starting point. Each one describes a behavior a user depends on. If any of them break silently, that is a failure.
Go through them and think about which are most at risk:
| Acceptance criterion | What could break it |
|---|---|
| Filter by status shows only matching posts | Wrong field passed to library, library bug, stale store data |
| Search returns accurate results | Library not receiving normalized query, posts not re-filtered on change |
| Post editor saves correctly | Validation not running, wrong fields sent to store |
| Posts persist after browser refresh | Storage library not connected to store, wrong key used |
| Slug is unique across posts | makeUnique not called, existing slugs not passed correctly |
The most important things to test are integration points — places where your code hands off to a library, or where two libraries' outputs compose together.
The filter pipeline is the highest priority:
user interaction → UI state → useMemo → library calls → rendered list
Five steps. A failure at any step produces wrong results silently. A test that exercises all five together is worth more than five tests that each check one step.
Look at your boundary map from Part 2.
For each PostKit acceptance criterion, write:
- Which boundary is responsible for satisfying it?
- What input would cause it to fail silently?
- Is this something you could verify manually in under 30 seconds? If not, it needs a test.
You will use this list in the next lesson to write test specifications.
A test that never fails is not a test. This was true for your library unit tests and it is true here.
A meaningful integration test:
- Fails when the behavior is wrong — if you break the code, the test breaks
- Passes when the behavior is correct — not just when no error is thrown
- Tests one specific behavior — narrow enough to tell you where the failure is
- Uses realistic inputs — the same kinds of data the real app would use
Signs a test is not meaningful:
// This test passes even if the filter does nothing
test('filter renders', () => {
render(<PostListView />)
expect(screen.getByText('Draft')).toBeInTheDocument()
})// This test passes even if the list is empty when it should not be
test('post list shows posts', () => {
render(<PostListView />)
// no assertion about which posts appear
})A meaningful version:
test('filtering by draft shows only draft posts', () => {
// arrange — seed the store with known data
const posts = [
makePost({ title: 'Draft post', status: 'draft' }),
makePost({ title: 'Published post', status: 'published' }),
]
usePostStore.setState({ posts })
// act — render and interact
render(<PostListView />)
fireEvent.click(screen.getByRole('button', { name: 'Draft' }))
// assert — verify the specific outcome
expect(screen.getByText('Draft post')).toBeInTheDocument()
expect(screen.queryByText('Published post')).not.toBeInTheDocument()
})This test would fail if:
- The filter button did not update the store
- The store did not update the visible list
- The library returned the wrong posts
- The component rendered the wrong posts
Any of those failures — the test catches it.
Create a file called TEST-TARGETS.md in the root of your PostKit repo. This is your deliverable for today — show it to the instructor before the end of class.
It should have two sections:
Section 1 — Boundary Map
Describe your system boundaries in text. For each boundary type, list the actual connections in your app:
## Boundary Map
### Library Boundaries
- PostListView.tsx: filterByStatus(posts, statusFilter) → Post[]
- PostListView.tsx: sortByDate(result, sortDirection) → Post[]
- PostDetailView.tsx: formatTime(readingTime(form.body)) → string
- ...
### State Boundaries
- usePostStore → posts: Post[] — read by PostListView, PostCard, PostDetailView
- usePostStore → updatePost — written by PostDetailView
- useUIStore → statusFilter — read by PostListView useMemo
- ...
### View Boundaries
- PostCard receives: post: Post — assumes tags is string[], title is non-empty
- PostDetailView receives: id via useParams — undefined means new post
- ...Section 2 — Test Targets
List at least five specific behaviors you want to verify. Use this format for each — you will paste these directly into AI prompts in lesson 10, so be precise:
## Test Targets
### T1 — Filter by status shows only matching posts
Behavior: Clicking the Draft filter shows only draft posts in the list
Setup: Store contains two posts — one draft, one published
Action: Render PostListView, click the button labeled 'Draft'
Assert:
- Draft post title appears in the document
- Published post title does NOT appear in the document
Failure: Would catch — wrong variable passed to filterByStatus, filter not connected to store
### T2 — ...You will use this file in lesson 10 to write test prompts for AI. A complete, specific TEST-TARGETS.md means lesson 10 starts with a clear plan. A vague one means lesson 10 starts with guesswork.
Show TEST-TARGETS.md to the instructor before leaving.
Share with the class:
- Which boundary in your app has the most traffic — the most things passing through it?
- Which acceptance criterion are you least confident is working correctly right now?
- What is one thing your boundary map revealed that you did not expect?
- Define each of the three boundary types in your own words. Give a specific example of each from your PostKit app — not a generic definition, but the actual file and function.
- Look at your boundary map. If the
Posttype gained a new required field, how many places in your app would need to change? List them. - Why is the filter pipeline a higher testing priority than, say, the date formatting library?
- Describe one behavior from your PostKit app that could break silently — the app would still run, but produce wrong results. Which boundary would the failure be at?