Learning By Doing In The Age of LLMs

October 19th, 2025

You can spend hours watching youtube videos or reading tutorials, but in order to truly learn and internalize a new skill, there's no substitute for doing the thing. You gotta "learn by doing" they say.

Programming is no different. As a self taught engineer, I've relied heavily on online courses and books to help expand my skills. The ones that were most effective always had some project-based component that pushed me to implement whatever concept I was learning. Beyond tutorials/books, my most in-depth learning has come from building my own projects - one line of code at a time, one bug fix at a time.

In the pre-LLM days, you had no choice but to do the work. If you wanted to build some software, you needed to execute all the little steps that it takes to build said software. This included research, thinking through design, understanding the different libraries involved, writing code, testing, debugging, deploying, and countless other steps to bring your software project to life. This was hard work, but it was ultimately work that you had to do, and by doing that work, you learned how to build (hopefully good) software. This was learning by doing.

The arrival of LLMs, and more specifically highly skilled agentic coding agents, has brought about a complete paradigm shift in how we build, and consequently, how we learn. The "doing" is now outsourced to Claude/Codex/your tool of choice, which means you're no longer doing the work, and therefore you're no longer learning from doing that work either. Initially "the work" here was limited to writing lines of code while you still needed to be heavily involved in the design phase and ensuring the agents stay on track. But the tools are rapidly evolving, and you can increasingly rely on them to produce relatively high quality software with decreasing amounts of human intervention.

There's a subtle tension to call out here, which is that effectively using these AI tools is also a skill, and one that's only going to become more important as the utility of these tools increases. So although you're no longer "learning by doing" the skills needed to build high quality software on your own, you are "learning by doing" the skills needed to have an LLM build high quality software on your behalf. The latter is no doubt valuable, but it comes at the expense of the former.

Is the former still valuable and worth protecting? One could question whether we need to even care about this erosion in our ability to build software without agentic assistance if agentic assistance is only going to get better over time. I can't predict the future - it's possible that in 5-10 years, building software will be wholly outsourced to agentic workflows in the cloud and there will be little to no need for a human in the loop. BUT that future hasn't yet arrived, and as anyone who works on a codebase of even moderate complexity can tell you, we're nowhere near realizing a fully end-to-end agent-driven method of building (and maintaining) software. As things stand today, I absolutely think it's important to pay attention to how you're learning as a software engineer and ensuring you still build the skills necessary to create without agentic assistance.

One way to explain my reasoning here is by thinking about "coding with agents" as just the newest programming language abstraction in our long history of abstractions. We started with machine code, then we got Assembly language, and then a series of higher level languages which progressively lower the barrier to entry of building software. Each layer hides the complexities of the layer beneath it, and by doing so, allows a lot more people than before to engage with the world of programming. Agentic programming - where you use English to instruct a machine to create software - can be seen as the latest abstraction in this long line, and it similarly has allowed a lot more people to get their hands dirty with building software. But here's the thing about programming (and this is probably true for any other field) - those who truly excel are those who understand the abstractions at least a few levels below the the surface. The engineer with a deeper understanding of how things work under the hood has a much higher likelihood of being effective on any given task. That kind of knowledge may not be necessary to get started but to fully reason through edge cases, system limits, performance issues, etc., you need to have some level of comfort with lower level concepts. So sure, the barrier to writing code and building software is much lower now thanks to agentic tools. But if you're only comfortable with the highest level of abstraction (programming using English to instruct coding agents) but have a weak grasp on the abstractions below it (reading/writing code, understanding data structures, system design principles, etc), your effectiveness as an engineer will be seriously compromised and your ability to even understand what you've built will be lacking.

If the abstraction framing doesn't convince you, it's worth reading Simon Willison's excellent Vibe Engineering post. Simon is incredibly wise (if you're not following him, I highly recommend you do) and has been deeply engaged in this space over the last many years - if there's someone who understands how to get the most value out of these agentic tools, it's him. In his post, he highlights the importance of building your own skills as an engineer if you want to get positive results from agentic coding agents because getting the most out of the agents is largely a function of having solid software engineering chops in the first place. The one point that particularly struck me was how working with agents is a "very weird form of management". The best managers & mentors I've had in my career have been those that actually understand what I'm doing beyond the surface level, and so I think in order to manage your coding assistants well and get the most out of them, you too will need to understand at a deeper level what they're doing.

Hopefully by now you're convinced it's still important to learn how to build good software, even as we continue to adopt coding agents to build said software. So now let's return to the original dilemma I raised a few paragraphs earlier - how can we make this learning happen if "learning by doing" was one of the most effective ways to learn and we are increasingly outsourcing the "doing" part of that equation to LLMs?

I wanted to share one pretty simple trick I've been using to address this. I've instructed Claude to write up a lesson document for any particular feature/bug fix/etc that I ask it to implement. Here's the LESSON_TEMPLATE.md file that it uses to do this:

LESSON_TEMPLATE.md

# Lesson [Number]: [Issue Title] > **Issue Reference**: #[number] in CODE_IMPROVEMENTS_TRACKER.md // This is a separate file with a list of improvements for the Delightful project > **Date**: [YYYY-MM-DD] > **Files Changed**: [List main files] > **Severity**: [Critical/High/Medium/Low] --- ## The Problem ### What Was Wrong? [Clear, non-technical explanation of the issue] ### Why Does It Matter? [Business/user impact - security risks, performance issues, user experience, maintainability] ### How Did We Discover This? [Code review, user report, analysis, etc.] --- ## Technical Concepts ### Core Concept #1: [Name] [Detailed explanation of the first key concept you need to understand] **Example:** ```python # Simple example demonstrating the concept ``` ### Core Concept #2: [Name] [Detailed explanation of the second key concept] **Real-World Analogy:** [Use a physical-world analogy to explain the concept] ### How This Relates to Our Codebase [Connect the abstract concepts to the specific problem in Delightful] --- ## The Solution ### Approach [Explain the strategy taken to fix the issue] ### Why This Approach? [Explain alternative solutions considered and why this one was chosen] ### Trade-offs - **Pros**: - [Benefit 1] - [Benefit 2] - **Cons**: - [Limitation 1] - [Limitation 2] --- ## Before & After ### Before ```python # Filename: path/to/file.py # Lines: XX-YY # Original code with inline comments explaining the issues def problematic_function(): # This is problematic because... pass ``` **Issues with this code:** 1. [Issue 1 with explanation] 2. [Issue 2 with explanation] 3. [Issue 3 with explanation] ### After ```python # Filename: path/to/file.py # Lines: XX-YY # Fixed code with inline comments explaining improvements def improved_function(): # This is better because... pass ``` **Improvements:** 1. [Improvement 1 with explanation] 2. [Improvement 2 with explanation] 3. [Improvement 3 with explanation] --- ## Impact Analysis ### Security [How this improves or affects security] - **Vulnerabilities Fixed**: [List] - **Attack Vectors Closed**: [List] ### Performance [Performance implications - better, worse, or neutral] - **Benchmarks**: [If applicable, show before/after metrics] - **Database Queries**: [Query count, N+1 issues, etc.] - **Memory Usage**: [If relevant] ### Maintainability [How this makes code easier to maintain] - **Code Clarity**: [Explanation] - **Future Extensions**: [How this makes future work easier] - **Dependencies**: [New dependencies added or removed] ### User Experience [How this affects end users] - **Latency**: [Changes to response time] - **Reliability**: [Changes to error rates] - **Features**: [Any user-facing changes] --- ## Testing Strategy ### What Was Tested ```python # Example test cases def test_example(): # Test implementation pass ``` ### Edge Cases Considered 1. [Edge case 1] 2. [Edge case 2] 3. [Edge case 3] ### How to Verify the Fix ```bash # Commands to run to verify the fix works python manage.py test path.to.test ``` --- ## Key Takeaways ### For This Specific Issue - βœ… [Key learning point 1] - βœ… [Key learning point 2] - βœ… [Key learning point 3] ### General Engineering Principles - 🎯 [Broader principle 1] - 🎯 [Broader principle 2] - 🎯 [Broader principle 3] ### What to Remember for Future Development - πŸ”” [Reminder 1 - what to watch for when writing new code] - πŸ”” [Reminder 2 - patterns to avoid] - πŸ”” [Reminder 3 - best practices to follow] --- ## Further Reading ### Essential Reading (Start Here) - [Title of resource](URL) - [Brief description of what you'll learn] - [Title of resource](URL) - [Brief description] ### Deep Dives (For Mastery) - [Title of resource](URL) - [Brief description] - [Title of resource](URL) - [Brief description] ### Related Documentation - Django Docs: [Relevant section](URL) - Python Docs: [Relevant section](URL) - Third-party library: [Relevant section](URL) ### Video Resources (If Available) - [Video title](URL) - [What it covers] --- ## Questions to Consider ### Understanding Check 1. [Question to verify understanding of the core concept] 2. [Question about why the old approach was problematic] 3. [Question about when to apply this pattern] ### Application Questions 1. [How would you apply this to scenario X?] 2. [What would happen if condition Y changed?] 3. [How does this interact with feature Z?] ### Advanced Thinking 1. [Question about edge cases or limitations] 2. [Question about scalability at 10x/100x scale] 3. [Question about alternative approaches] --- ## Reflection ### Before You Move On Take a moment to reflect: 1. **What was the most surprising thing you learned?** - [Your answer] 2. **What concept do you want to explore more?** - [Your answer] 3. **How will this change how you write code?** - [Your answer] 4. **What similar issues might exist in the codebase?** - [Your answer] --- ## Related Lessons - Lesson #[X]: [Title] - [How it relates] - Lesson #[Y]: [Title] - [How it relates] ---

My workflow is usually to have Claude implement some atomic unit of work on a branch (e.g. `feature/social-auth` or `bug-fix/fix-navigation-issues`), and then once I'm satisfied with the work, I'll have it generate a LESSON file from this template based on whatever changes it made on that branch. Aside from the code changes, it can also draw from our conversation history which includes all the back and forths we had to get to the final solution.

Here's an example lesson it filled out after I had it add a whole bunch of test coverage to the React Native codebase for Delightful (something I overlooked when trying to quickly get it out the door in time for Dry January... Don't judge me).

LESSON.md - React Native Testing Example

# Lesson 2: Comprehensive React Native Testing Strategy **Concepts**: Test-Driven Development, Jest, React Native Testing Library, Mocking, Test Coverage, Integration Testing, Unit Testing, Context Testing, Hook Testing **Complexity**: Medium to High - Testing React Native requires understanding of components, contexts, hooks, async operations, and mobile-specific APIs **Key Takeaway**: Comprehensive testing enables confident refactoring, AI-assisted development, and maintainable codebases. Testing isn't just about catching bugsβ€”it's about documenting behavior and enabling change. --- ## 1. The Problem The Delightful mobile app had **minimal test coverage** (11 test suites, 94 tests). This created several critical problems: ### Technical Risks - **Regression fears**: Developers hesitated to refactor code, fearing unexpected breakage - **Slow review cycles**: PRs required extensive manual testing of edge cases - **Integration bugs**: Components worked in isolation but broke when combined - **Undocumented behavior**: New developers couldn't understand expected behavior from code alone ### AI Development Blockers - AI agents can't verify their changes work correctly - No safety net for automated refactoring - Complex logic (like filters) is opaque to AI understanding - Breaking changes go undetected until runtime ### Real-World Impact - Authentication edge cases weren't tested (What happens when the token expires during form submission?) - Filter logic complexity made debugging nearly impossible - New features broke existing workflows - Mobile-specific issues (keyboard handling, native events) weren't tested --- ## 2. Technical Concepts ### 2.1 Testing Philosophy #### The Testing Pyramid ``` /\ / \ E2E Tests (Few, Slow, Expensive) /----\ / \ Integration Tests (Some, Medium) /--------\ / \ Unit Tests (Many, Fast, Cheap) /------------\ ``` **Unit Tests**: Test individual functions in isolation - Example: `capitalizeFirstLetter('john')` β†’ `'John'` - Fast, focused, easy to debug **Integration Tests**: Test how components work together - Example: SearchBar + SearchContext + API hooks - Medium speed, broader coverage **E2E Tests**: Test complete user flows - Example: Sign up β†’ Login β†’ Create review β†’ View profile - Slow, fragile, but high confidence - *Note: We focused on unit and integration tests for this project* ### 2.2 React Native Testing Library Unlike traditional DOM testing, React Native uses a **native component tree**. React Native Testing Library provides: #### Query Methods ```typescript // Find by text content getByText('Login') // Find by placeholder getByPlaceholderText('Email') // Find by test ID getByTestId('submit-button') // Find by role/type (uses UNSAFE_root) UNSAFE_root.findAllByType('TextInput') ``` #### User Event Simulation ```typescript // Simulate user typing fireEvent.changeText(input, 'test@example.com') // Simulate button press fireEvent.press(button) // Wait for async operations await waitFor(() => { expect(mockFunction).toHaveBeenCalled() }) ``` [...] --- ## 3. The Solution We implemented a **comprehensive testing strategy** across 7 categories: ### Phase 1: Foundation - Utils & Core Logic (Priority 1) **Goal**: Test pure functions firstβ€”they're easiest and provide immediate value βœ… **Created**: - `utils/auth.test.ts` - Authentication functions (7 tests, 100% coverage) - `utils/api.test.tsx` - API layer with token refresh (10 tests, 97.61% coverage) - `utils/text.test.ts` - Text utilities (26 tests, 100% coverage) - `utils/time.test.ts` - Time formatting (15 tests, 100% coverage) **Key Patterns**: ```typescript // Testing async functions it('should create user with capitalized name', async () => { mockedAxios.post.mockResolvedValueOnce({ data: mockUser }) const result = await createUser({ first_name: 'john', last_name: 'doe', email: 'test@example.com', password: 'password123', }) expect(result.data.first_name).toBe('John') expect(result.data.last_name).toBe('Doe') }) // Testing error handling it('should handle 401 and refresh token', async () => { // First call fails with 401 mockedRequest.mockRejectedValueOnce({ response: { status: 401 } }) // Refresh succeeds mockedRefresh.mockResolvedValueOnce({ data: { access: 'new-token', refresh: 'new-refresh' } }) // Retry succeeds mockedRequest.mockResolvedValueOnce({ data: 'success' }) await wrappedRequest() expect(mockAuthenticate).toHaveBeenCalledWith('new-token', 'new-refresh') }) ``` ### Phase 2: UI Components (Priority 3) **Goal**: Test reusable components in isolation βœ… **Created**: - `components/ui/__tests__/NewButton.test.tsx` - Button variants, states (9 tests) - `components/ui/__tests__/IconButton.test.tsx` - Icon buttons (5 tests) - `components/ui/__tests__/FlatButton.test.tsx` - Flat button styles (4 tests) - `components/ui/__tests__/Link.test.tsx` - Navigation links (3 tests) - `components/ui/__tests__/Text.test.tsx` - Text component (1 test) - `components/ui/__tests__/SectionHeader.test.tsx` - Section headers (2 tests) - `components/ui/__tests__/LoadingOverlay.test.tsx` - Loading states (1 test) **Key Patterns**: ```typescript // Testing component rendering it('should render with text', () => { const { getByText } = render( <NewButton onPress={() => {}}>Click Me</NewButton> ) expect(getByText('Click Me')).toBeTruthy() }) // Testing variants it('should handle secondary variant', () => { const { getByText } = render( <NewButton variant="secondary" onPress={() => {}}> Secondary </NewButton> ) const button = getByText('Secondary').parent expect(button?.props.style).toContainEqual( expect.objectContaining({ backgroundColor: COLORS.SECONDARY }) ) }) // Testing disabled state it('should not call onPress when disabled', () => { const mockPress = jest.fn() const { getByText } = render( <NewButton disabled onPress={mockPress}>Press</NewButton> ) fireEvent.press(getByText('Press')) expect(mockPress).not.toHaveBeenCalled() }) ``` ### Phase 3: Contexts - State Management (Priority 4) **Goal**: Test global state and complex state transitions βœ… **Created**: - `contexts/__tests__/AuthContext.test.tsx` - Auth state (30 tests, 86.27%) - `contexts/__tests__/ProductFiltersContext.test.tsx` - Filters (85 tests, 41.5%) - `contexts/__tests__/SearchContext.test.tsx` - Search (7 tests, 87.5%) - `contexts/__tests__/ProductListBottomSheetContext.test.tsx` - Bottom sheet (32 tests, 81.25%) **Key Patterns**: ```typescript // Testing context providers it('should initialize with default state', () => { const TestComponent = () => { const { isAuthenticated } = useAuthContext() return <Text>{isAuthenticated ? 'Yes' : 'No'}</Text> } const { getByText } = render( <AuthProvider> <TestComponent /> </AuthProvider> ) expect(getByText('No')).toBeTruthy() }) // Testing state updates it('should update authentication state', () => { const TestComponent = () => { const { authenticate, isAuthenticated } = useAuthContext() return ( <View> <Text>{isAuthenticated ? 'Authenticated' : 'Not Authenticated'}</Text> <Button onPress={() => authenticate('token', 'refresh', false)}> Login </Button> </View> ) } const { getByText } = render( <AuthProvider> <TestComponent /> </AuthProvider> ) expect(getByText('Not Authenticated')).toBeTruthy() fireEvent.press(getByText('Login')) expect(getByText('Authenticated')).toBeTruthy() }) // Testing AsyncStorage persistence it('should persist tokens to AsyncStorage', async () => { const { result } = renderAuthProvider() await act(async () => { result.authenticate('access-token', 'refresh-token', false) }) await waitFor(() => { expect(AsyncStorage.setItem).toHaveBeenCalledWith('accessToken', 'access-token') expect(AsyncStorage.setItem).toHaveBeenCalledWith('refreshToken', 'refresh-token') }) }) ``` ### Phase 4: Data Hooks (Priority 5) **Goal**: Test API integration and data fetching logic βœ… **Created**: - `hooks/data/__tests__/useFlavors.test.tsx` - Flavor fetching (2 tests, 100%) - `hooks/data/__tests__/useProductsSearch.test.tsx` - Product search (45 tests, 92.85%) **Key Patterns**: ```typescript // Testing React Query hooks it('should fetch products with filters', async () => { const mockProducts = [ { id: 1, name: 'Product 1', category: 'BEER' }, { id: 2, name: 'Product 2', category: 'BEER' }, ] mockedUseQueryApi.mockReturnValue({ data: { results: mockProducts }, isLoading: false, error: null, }) const TestComponent = () => { const { products } = useProductsSearch({ category: 'BEER' }) return ( <View> {products.map(p => <Text key={p.id}>{p.name}</Text>)} </View> ) } const { getByText } = render(<TestComponent />) expect(getByText('Product 1')).toBeTruthy() expect(getByText('Product 2')).toBeTruthy() }) // Testing filter application it('should apply multiple filters', () => { const filters = { category: 'BEER', subcategories: ['IPA', 'LAGER'], minRating: 4.0, flavors: ['Hoppy', 'Crisp'], } const result = useProductsSearch(filters) expect(mockedUseQueryApi).toHaveBeenCalledWith( expect.stringContaining('category=BEER'), expect.objectContaining({ enabled: true, }) ) }) ``` ### Phase 5: Screens - User Flows (Priority 2) **Goal**: Test critical user journeys βœ… **Created**: - `screens/unauthenticated/__tests__/WelcomeScreen.test.tsx` - Welcome flow (5 tests, 100%) - `screens/unauthenticated/__tests__/EmailAuthScreen.test.tsx` - Auth flow (9 tests, 90.38%) **Key Patterns**: ```typescript // Testing form submission it('should handle successful login', async () => { const mockTokens = { access: 'token', refresh: 'refresh' } mockedLogin.mockResolvedValueOnce({ data: mockTokens }) const { getByPlaceholderText, getByText } = render( <EmailAuthScreen navigation={mockNav} route={loginRoute} />, { wrapper: AuthWrapper } ) fireEvent.changeText(getByPlaceholderText('Email'), 'test@example.com') fireEvent.changeText(getByPlaceholderText('Password'), 'password123') fireEvent.press(getByText('Log In')) await waitFor(() => { expect(mockedLogin).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', }) expect(mockAuthContext.authenticate).toHaveBeenCalledWith( 'token', 'refresh', false ) }) }) // Testing error handling it('should display error on failed login', async () => { mockedLogin.mockRejectedValueOnce(new Error('Invalid credentials')) const { getByPlaceholderText, getByText } = render( <EmailAuthScreen navigation={mockNav} route={loginRoute} />, { wrapper: AuthWrapper } ) fireEvent.changeText(getByPlaceholderText('Email'), 'wrong@example.com') fireEvent.changeText(getByPlaceholderText('Password'), 'wrong') fireEvent.press(getByText('Log In')) await waitFor(() => { expect(Alert.alert).toHaveBeenCalledWith( 'Authentication Failed!', expect.stringContaining('check your credentials') ) }) }) // Testing mode switching it('should switch between login and signup modes', async () => { const { getByText, getByPlaceholderText } = render( <EmailAuthScreen navigation={mockNav} route={loginRoute} /> ) fireEvent.press(getByText('Or create an account')) await waitFor(() => { expect(getByPlaceholderText('First Name')).toBeTruthy() expect(getByPlaceholderText('Last Name')).toBeTruthy() }) }) ``` ### Phase 6: Complex Components (Priority 6) **Goal**: Test feature-specific components βœ… **Created**: - `screens/.../components/Flavors/__tests__/FlavorChip.test.tsx` - Flavor chips (4 tests, 100%) - `screens/.../components/ProductSearch/__tests__/SearchBar.test.tsx` - Search bar (14 tests, 93.75%) - `screens/.../components/ProductSearch/__tests__/ProductSearchTile.test.tsx` - Product tiles (8 tests, 100%) - `screens/.../components/ProductSearch/__tests__/SubcategoryFilters.test.tsx` - Subcategory filters (51 tests, 100%) **Key Patterns**: ```typescript // Testing with context dependencies const mockSearchContext = { searchQuery: '', setSearchQuery: jest.fn(), clearSearch: jest.fn(), } const renderSearchBar = (contextValue = mockSearchContext) => { return render( <SearchContext.Provider value={contextValue}> <SearchBar placeholderText="Search products" /> </SearchContext.Provider> ) } // Testing input changes it('should update search query on text change', () => { const { getByPlaceholderText } = renderSearchBar() fireEvent.changeText(getByPlaceholderText('Search products'), 'IPA') expect(mockSearchContext.setSearchQuery).toHaveBeenCalledWith('IPA') }) // Testing debouncing/timing it('should clear search when clear button pressed', () => { const { getByTestId } = renderSearchBar({ ...mockSearchContext, searchQuery: 'existing query', }) fireEvent.press(getByTestId('clear-search')) expect(mockSearchContext.clearSearch).toHaveBeenCalled() }) ``` ### Phase 7: Hooks - Custom Business Logic **Goal**: Test non-data hooks βœ… **Created**: - `hooks/__tests__/useDeepLink.test.tsx` - Deep link handling (4 tests, 94.11%) --- ## 4. Before & After ### Before: Minimal Coverage ``` Test Suites: 11 passed, 11 total Tests: 94 passed, 94 total Coverage: ~20% (estimated, not formally measured) ``` **Problems**: - No screen tests - No component tests - Minimal context tests - No integration tests - Manual testing required for every change ### After: Comprehensive Coverage ``` Test Suites: 24 passed, 24 total Tests: 313 passed, 313 total Coverage: 78.42% statements, 79.36% branches Breakdown: - Utils: 99.06% βœ… - Data Hooks: 93.22% βœ… - Tested Screens: 91.22% βœ… - Product Components: 96.29% βœ… - Contexts: 59.66% ⚠️ - UI Components: 64.40% ⚠️ ``` **Benefits**: - Fast test execution (<3s) - Automated regression detection - Documentation through tests - Confident refactoring - AI-friendly codebase --- ## 5. Impact Analysis ### Development Velocity **Before**: Fear of breaking changes slowed development **After**: Tests provide safety net for rapid iteration ### Code Quality **Before**: Undocumented edge cases, hidden assumptions **After**: Tests document expected behavior explicitly ### Onboarding **Before**: New developers struggled to understand component interactions **After**: Tests serve as executable documentation ### AI Development Readiness **Before**: AI agents couldn't verify changes **After**: AI can run tests to validate modifications | Capability | Before | After | |------------|--------|-------| | Refactor utils | ❌ Risky | βœ… Safe (99% coverage) | | Modify API layer | ❌ Manual testing | βœ… Automated (97% coverage) | | Update filters | ❌ Very risky | ⚠️ Needs improvement (41%) | | Change screens | ❌ No tests | ⚠️ Partial (2 screens tested) | ### Performance - Test suite runs in <3 seconds - Coverage report in <5 seconds - No impact on bundle size (dev dependency) ### Maintenance - Established patterns for future tests - Consistent mocking strategy - Reusable test utilities --- ## 6. Testing Strategy ### 6.1 Test Organization ``` component/ β”œβ”€β”€ Component.tsx └── __tests__/ └── Component.test.tsx ``` **Benefits**: - Easy to find related tests - Clear ownership - Encourages testing ### 6.2 What to Test βœ… **Do Test**: - User interactions (button clicks, text input) - Conditional rendering (loading, error, success states) - Props variations (different button variants) - State changes (filter updates, auth state) - Error handling (API failures, validation) - Integration between components and contexts ❌ **Don't Test**: - Implementation details (internal state variables) - Third-party libraries (React Navigation, React Query) - Styling (unless it affects functionality) - Exact text copy (use `getByText(/pattern/i)` for flexibility) ### 6.3 Mock Strategy #### Level 1: Native Modules (jest-setup.js) ```typescript // Mock once, use everywhere jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter') jest.mock('@expo/vector-icons') jest.mock('expo-constants') ``` #### Level 2: External Dependencies (test file) ```typescript // Mock per test file jest.mock('@/utils/auth') jest.mock('axios') jest.mock('@react-navigation/native') ``` #### Level 3: Internal Mocks (test case) ```typescript // Mock per test case beforeEach(() => { mockedLogin.mockResolvedValue({ data: mockTokens }) }) afterEach(() => { jest.clearAllMocks() }) ``` ### 6.4 Testing Checklist For each component/function: - [ ] Renders correctly with default props - [ ] Handles all prop variations - [ ] Responds to user interactions - [ ] Shows loading states - [ ] Shows error states - [ ] Shows empty states - [ ] Handles edge cases (null, undefined, empty arrays) - [ ] Integrates with contexts correctly - [ ] Makes correct API calls - [ ] Navigates correctly --- ## 7. Key Takeaways ### Specific to This Project 1. **Start with utils** - Pure functions are easiest to test and provide immediate value 2. **Mock early, mock consistently** - Establish mocking patterns in `jest-setup.js` 3. **Test behavior, not implementation** - Focus on what users experience 4. **Context wrappers are essential** - Most components depend on global state 5. **Async testing requires patience** - Use `waitFor()` liberally ### General Engineering Principles 1. **Tests are documentation** - Future developers (and AI) learn from tests 2. **Coverage isn't everything** - 78% coverage beats 100% of the wrong things 3. **Test the critical path first** - Auth and core features before edge cases 4. **Integration > Unit** - Component + Context tests catch more bugs than isolated unit tests 5. **Refactor with confidence** - Good tests let you change code fearlessly ### Mobile-Specific Insights 1. **Native APIs need mocking** - Keyboard, AsyncStorage, Push Notifications 2. **Event emitters are tricky** - Mock `NativeEventEmitter` to avoid warnings 3. **Navigation is context** - Mock navigation props and contexts separately 4. **Async is everywhere** - API calls, storage, native modules all require `waitFor()` --- ## 8. Further Reading ### React Native Testing Library - [Official Docs](https://callstack.github.io/react-native-testing-library/) - [Common Mistakes](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) - [Testing Playground](https://testing-playground.com/) ### Jest - [Jest Documentation](https://jestjs.io/docs/getting-started) - [Mocking Guide](https://jestjs.io/docs/mock-functions) - [Async Testing](https://jestjs.io/docs/asynchronous) ### Testing Philosophy - [Testing Trophy](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) - Why integration tests matter - [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests) - Guillermo Rauch's famous quote - [Testing Implementation Details](https://kentcdodds.com/blog/testing-implementation-details) - What not to test --- *"Code without tests is broken by design." - Jacob Kaplan-Moss* *"The best time to write tests was when you wrote the code. The second best time is now."*
It even throws in some cute quotes at the end

I've found this to be a great way to stay on top of my own learning while still embracing agentic tools and reaping all the benefits they provide. I'm sure others have felt the nagging sense that the more we use these tools, the less we understand what we're building, and this has been a useful antidote for that feeling. The "Further Reading" section of the template at the very end has been most helpful - It's led me to learn entirely new concepts that I wouldn't have encountered otherwise. Of course, this isn't a perfect solution because I'm still not doing all the implementation work myself and therefore not learning in the same way I used to. But something is better than nothing and just taking the time to review these lessons it puts together and using them to go deeper into foundational concepts has been a massive help.

What I've described here is very much a template and you could extend this pattern based on your own preferences and requirements. What's nice about these agentic tools is you really can shape the output to best suit your needs. For example, it shouldn't be too hard to have Claude put together practice exercises or quizzes on these lesson docs, and that in turn forces you to reclaim some of that "learning by doing" you ceded to the LLM in the first place.

Of course, actually learning from these lesson docs will take time and effort and follow through, but I guess that's always been the case with learning. Ultimately, you still gotta do the work.