Building a Robust React Native Foundation for Complex Apps (Part 2: Architecture & Developer Workflow)

Thomas EjembiThomas Ejembi
12 min read

In mobile app development, ensuring software works as intended, bug-free, and meets user needs is paramount. This is where Software Testing comes in—a critical process for any production-ready application like "Pawfect." Comprehensive testing helps us proactively identify and fix issues, significantly reducing bugs and saving time compared to manual checks.

In this section, I'll share my thought process and decisions behind setting up "Pawfect's" robust testing strategies, covering unit, integration, and end-to-end tests. This lays the foundation for our Continuous Integration (CI) process, which we'll delve into immediately after to see how we automate these quality checks.

Testing Strategies: Ensuring "Pawfect" Quality

Building mobile apps is great, but let's face it, the real challenge is making sure they function as intended, free of bugs, and satisfy the needs of all users. And for a project like "Pawfect," that's precisely where software testing comes in. It's our safety net, not just a fancy buzzword. In comparison to manually searching for bugs, thorough testing allows us to identify unpleasant surprises early on, which ultimately saves us a great deal of time and headaches.

In this section, I'll walk you through my thought process and decisions behind setting up "Pawfect's" core testing strategy, focusing primarily on unit and component tests. This lays the foundation for our Continuous Integration (CI) process, which we'll dive into next to see how we automate all these quality checks.

Unit and Component Testing: The First Line of Defense

We begin "Pawfect" with modest but effective unit and component tests. Consider these our first line of defense. They enable us to verify that each tiny building block performs its function flawlessly before it is put together with other components, including individual functions, isolated logical segments, and even entire components. It's our fastest method of receiving feedback, allowing us to find bugs early on when they're most affordable and easy to fix.

Our go-to tools here are Jest, which is like our fast and furious test runner, paired with React Native Testing Library. The Testing Library is awesome because it pushes us to test components the way a real user would interact with them, making our tests way more reliable and less likely to break just because we tweaked some internal code.

Here’s how our jest.config.js and __tests__/setup.test.ts files are set up to power all this:

JavaScript

// jest.config.js
module.exports = {
  // Kicking things off with React Native's default Jest config – no need to reinvent the wheel!
  preset: 'react-native',

  // These files run once before all our tests. Great for global setups or adding custom test helpers.
  setupFilesAfterEnv: [
    '@testing-library/jest-native/extend-expect', // Gives us cool custom matchers for React Native elements
    '<rootDir>/__tests__/setup.test.ts', // Our own little custom setup for specific mocks
  ],

  // What file types Jest should keep an eye out for.
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],

  // Which files actually *are* our tests. We prefer to keep them neatly in __tests__ for now.
  testMatch: [
    '<rootDir>/__tests__/**/*.(ts|tsx|js)',
  ],

  // How Jest transforms our TypeScript/JSX into plain old JavaScript using Babel.
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },

  // Files Jest should *ignore* transforming in node_modules – speeds things up big time!
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|react-native-gesture-handler|react-native-reanimated|@react-navigation|react-native-screens|react-native-safe-area-context|react-native-svg)/)',
  ],

  // Making sure our cool module aliases (like @features or @shared) work in tests too. Keeps imports clean!
  moduleNameMapper: {
    '^@assets/(.*)$': '<rootDir>/src/assets/$1',
    '^@hooks/(.*)$': '<rootDir>/src/shared/hooks/$1',
    '^@navigator/(.*)$': '<rootDir>/src/navigator/$1',
    '^@shared/(.*)$': '<rootDir>/src/shared/$1',
    '^@features/(.*)$': '<rootDir>/src/features/$1',
    '^@store/(.*)$': '<rootDir>/src/store/$1',
    '^@utils/(.*)$': '<rootDir>/src/shared/utils/$1',
    '^@types$': '<rootDir>/src/shared/types/index.ts',
  },

  // --- Code Coverage Settings ---
  collectCoverage: true, // Yep, we want to know how much of our code is covered!
  // What files to include/exclude from those coverage reports. We skip configs, tests themselves, etc.
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/__tests__/**',
    '!src/**/*.test.{ts,tsx}',
    '!src/**/*.spec.{ts,tsx}',
    '!src/index.tsx',
    '!src/App.tsx',
  ],
  coverageThreshold: {
    global: {
      branches: 1, // These are just placeholders for now; we'll bump them up as our test suite matures!
      functions: 1,
      lines: 1,
      statements: 1,
    },
  },
  coverageDirectory: 'coverage', // Where to dump all those useful coverage reports
  coverageReporters: ['text', 'lcov', 'html', 'clover'], // Different formats for different needs

  // We're running tests in a Node.js environment.
  testEnvironment: 'node',

  // Wiping the slate clean after each test – no weird side effects between tests!
  clearMocks: true,

  // Super verbose output when running tests, especially handy for CI so we can see what's going on.
  verbose: true,

  // Using half our available worker processes – good for balancing speed and resource usage, especially in CI.
  maxWorkers: '50%',

  // Where Jest stashes its cache.
  cacheDirectory: '<rootDir>/node_modules/.cache/jest',
};

And here's our __tests__/setup.test.ts file, where we set up crucial global mocks and add some handy Jest matchers:

TypeScript

// __tests__/setup.test.ts
import 'react-native-gesture-handler/jestSetup'; // Essential setup for the gesture handler library
import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; // Mocking safe area context to prevent native module issues in tests
import '@testing-library/jest-native/extend-expect'; // Gives us cool new matchers that understand React Native elements

// Here's where we mock out specific React Native modules that often cause hiccups in UI tests.
jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
// You'd add other global mocks here too, if you have other common third-party libraries that need special handling.

Continuous Integration: Automating Quality and Builds

After setting up our development workflow and establishing a solid testing strategy, the next logical step for a production-ready app like "Pawfect" is to automate. Continuous Integration (CI) becomes essential in this situation. CI is the process of automatically building and testing code after it has been regularly merged into a central repository. This gives us quick feedback and maintains the stability of our main branch by assisting us in identifying integration problems and quality regressions early.

We have selected CircleCI as our CI platform for "Pawfect." It is reliable, adaptable, and blends in perfectly with our varied toolchain and monorepo structure. Our automated quality checks and builds are coordinated by our .circleci/config.yml file, which guarantees that every code change is carefully reviewed.

Here’s a breakdown of our CircleCI configuration:

YAML

# .circleci/config.yml
version: 2.1
# Importing the Snyk Orb for easy integration
orbs:
  snyk: snyk/snyk@2.3.0

# Define reusable commands to keep our jobs clean and DRY (Don't Repeat Yourself)

commands:
  setup_node:
    description: 'Setup Node.js and install Node dependencies, including Snyk scanning'
    steps:
      - checkout # Pulls the latest code from your repository
      - restore_cache: # Attempts to restore cached node_modules to speed up builds
          keys:
            - v1-dependencies-{{ checksum "package.json" }} # Cache key based on package.json hash
            - v1-dependencies- # Fallback cache key
      - run:
          name: Install Node Dependencies
          command: yarn install # Installs all JavaScript/TypeScript dependencies
      - snyk/scan: # Snyk Orb integration for vulnerability scanning
          fail-on-issues: false # Don't fail the build even if high-severity issues are found (for now)
          monitor-on-build: true # Sends results to Snyk dashboard for continuous monitoring
          severity-threshold: high # Scans for issues with 'high' severity or above
      - save_cache: # Caches node_modules for future builds
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}

  setup_android:
    description: 'Setup Android environment variables and SDK paths'
    steps:
      - run:
          name: Setup Android SDK
          command: | # Exports necessary Android environment variables for Gradle builds
            echo 'export ANDROID_HOME=/opt/android/sdk' >> $BASH_ENV
            echo 'export PATH=$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$PATH' >> $BASH_ENV
            source $BASH_ENV # Sources the environment to make changes effective immediately

# Define our build jobs

jobs:
  test_and_lint: # This job focuses on code quality and correctness
    docker:
      - image: cimg/node:18.18 # Uses a Node.js 18 Docker image for consistency
    steps:
      - setup_node # Runs our reusable Node.js setup command
      - run:
          name: Run Tests
          command: npm test -- --coverage --watchAll=false # Executes Jest tests, collects coverage, and disables watch mode for CI
      - run:
          name: Run Linting
          command: npm run lint # Runs ESLint checks
      - run:
          name: Type Check (if using TypeScript)
          command: npm run type-check # Performs TypeScript type checking
      - store_test_results: # Stores test results for CircleCI's UI
          path: ./coverage # Path to test reports (Jest generates XML reports here)
      - store_artifacts: # Stores coverage reports as artifacts for later inspection
          path: ./coverage

  build_android_debug: # Job for building the Android debug APK
    docker:
      - image: cimg/android:2023.12-node # Uses an Android-specific Docker image with Node.js
    resource_class: large # Requests a larger resource class for faster Android builds
    steps:
      - setup_node # Setup Node.js dependencies
      - setup_android # Setup Android environment
      - restore_cache: # Restores cached Gradle dependencies
          keys:
            - v1-gradle-{{ checksum "android/gradle/wrapper/gradle-wrapper.properties" }}
            - v1-gradle-
      - run:
          name: Download Dependencies
          command: cd android && ./gradlew androidDependencies # Downloads Android project dependencies
      - save_cache: # Caches Gradle dependencies
          paths:
            - ~/.gradle
          key: v1-gradle-{{ checksum "android/gradle/wrapper/gradle-wrapper.properties" }}
      - run:
          name: Build Android Debug APK
          command: cd android && ./gradlew assembleDebug # Builds the Android debug APK
      - store_artifacts: # Stores the resulting APK as an artifact
          path: android/app/build/outputs/apk/debug/
          destination: android-debug-apk # Renames the artifact

  build_ios_debug: # Job for building the iOS debug app
    macos:
      xcode: 15.0.0 # Specifies the Xcode version for the macOS executor
    resource_class: macos.m1.medium.gen1 # Requests an M1 Mac resource class for faster iOS builds
    steps:
      - checkout
      - restore_cache: # Restores Node dependencies (duplicated for iOS as it's a fresh machine)
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            - v1-dependencies-
      - run:
          name: Install Node Dependencies
          command: yarn install
      - snyk/scan: # Snyk scan also runs here for iOS build dependencies
          fail-on-issues: false
          monitor-on-build: true
          severity-threshold: high
      - restore_cache: # Restores cached CocoaPods
          keys:
            - v1-pods-{{ checksum "ios/Podfile.lock" }}
            - v1-pods-
      - run:
          name: Install CocoaPods
          command: | # Installs CocoaPods for iOS native dependencies
            cd ios
            pod install
      - save_cache: # Caches CocoaPods
          paths:
            - ios/Pods
          key: v1-pods-{{ checksum "ios/Podfile.lock" }}
      - run:
          name: Build iOS Debug
          command: | # Builds the iOS debug app using xcodebuild
            cd ios
            xcodebuild -workspace Pawsfect.xcworkspace \
                       -scheme Pawsfect \
                       -configuration Debug \
                       -destination generic/platform=iOS \
                       -derivedDataPath build/ \
                       build
      - store_artifacts: # Stores the iOS build output as an artifact
          path: ios/build/
          destination: ios-debug-build

# Define the workflow that orchestrates the jobs
workflows:
  version: 2
  development_workflow: # This workflow runs on every push to the repository
    jobs:
      - test_and_lint # First, run all tests and linting checks
      - build_android_debug: # Build Android debug APK
          requires: # Only run after test_and_lint successfully completes
            - test_and_lint
      - build_ios_debug: # Build iOS debug app
          requires: # Only run after test_and_lint successfully completes
            - test_and_lint
          context: snyk-context # Uses a CircleCI context to securely inject Snyk API token

Our CircleCI setup offers quick feedback on each code change in "Pawfect" and is made to be effective, thorough, and secure. This is the reasoning behind it:

  1. Orchestrated Workflow (and version: 2.1): We're using CircleCI's version: 2.1 config syntax, which allows for reusable commands and orbs. This keeps our .circleci/config.yml clean, readable, and prevents repetitive code, especially useful as our pipeline grows. The development_workflow ensures our jobs run in a logical sequence, with builds only starting after all quality checks pass.

  2. Shared Setup Commands for Efficiency:

    • setup_node: This command is a reliable tool. It manages code checks, node_modules cache restoration (a major time-saver!), installing new dependencies, and—most importantly—executing Snyk scans.

      • Snyk Integration: Integrating snyk/scan directly into our setup_node command means every single build automatically checks for known vulnerabilities in our dependencies. As our security posture develops, we can easily tighten this (fail-on-issues: true for high or critical severity). At first, setting fail-on-issues: false and monitor-on-build: true gives us visibility on issues without halting development. Preventing supply chain attacks and preserving "Pawfect's" security.
    • setup_android: This command makes sure our Android build job has all the required resources and that the Android SDK environment is set up properly for native builds.

  3. Dedicated Quality Gate: test_and_lint Job:

    • This job is our primary quality gate. It runs on a lightweight cimg/node Docker image, making it fast.

    • It executes all our Jest tests (with coverage enabled), runs ESLint checks, and performs TypeScript type checking. This holistic approach ensures not just functionality, but also code style consistency and type safety.

    • Storing Results & Artifacts: We store_test_results so CircleCI can display test summaries right in the UI, and store_artifacts for code coverage reports (lcov, html, etc.) which are vital for local debugging or for external tools like SonarQube.

      • SonarQube Integration: While not explicit in the YAML (as it's often a separate scanner run against generated reports), this test_and_lint job generates the lcov (code coverage) and test reports that SonarQube consumes. A typical SonarQube setup would involve a separate step (often as part of the test_and_lint job or a dedicated SonarQube job) where sonar-scanner is run, pointing to these generated reports and pushing results to the SonarQube server. This provides deep static analysis, identifies code smells, and tracks technical debt, ensuring our codebase health continuously improves.
  4. Platform-Specific Debug Builds:

    • build_android_debug: Because Android builds require more resources, this job uses a cimg/android image and a large resource class. It creates a debug APK and effectively caches Gradle dependencies, storing it as an artifact for convenient access.

    • build_ios_debug: This job utilizes a macos.m1.medium.gen1 resource class (hello, M1 Macs for faster iOS!), reflecting the need for a macOS environment for iOS builds. It installs Node dependencies (again, with Snyk scanning for iOS-specific dependencies!), restores CocoaPods cache, installs pods, and finally builds the iOS debug app using xcodebuild. The snyk-context ensures our Snyk API token is securely injected during this job.

  5. Sequential Workflow (development_workflow):

    • The development_workflow ensures test_and_lint must pass before any platform-specific builds even begin. This is a crucial quality gate: we don't waste build resources on code that fails basic tests or linting. This sequential approach saves time and ensures only validated code proceeds.

By ensuring that every merge request to "Pawfect" is rigorously tested, linted, type-checked, and scanned for security flaws, this extensive CI pipeline keeps our main branch (or develop branch for staging) stable and our application strong.

Visualizing the Continuous Integration Pipeline

To truly grasp how all these pieces fit together, let's visualize our CircleCI workflow. This diagram illustrates the sequence of jobs, their dependencies, and where our key quality tools like Snyk and SonarQube come into play.

%%{init: {'theme': 'neutral', 'fontFamily': 'Arial', 'gantt': {'barHeight': 20}}}%%
flowchart TD
    A([Git Push / PR]) --> B[setup_node]

    subgraph Phase 1: Shared Setup
    B --> B1["📂 Checkout Code"]
    B1 --> B2["💾 Restore Cache (node_modules)"]
    B2 --> B3["🐉 yarn install"]
    B3 --> B4["🛡️ Snyk Scan"]
    B4 --> B5["💾 Save Cache"]
    end

    B --> C[[Job: test_and_lint]]

    subgraph Phase 2: Quality Gate
    C --> C1["🧪 Jest Tests"]
    C1 --> C2["✏️ ESLint/Prettier"]
    C2 --> C3["⚙️ TypeScript Check"]
    C3 --> C4("📊 Store Test Results")
    C3 --> C5("📈 Store Coverage")
    C5 -.-> D[SonarQube Analysis]
    end

    C --> E[[Job: build_android_debug]]
    C --> F[[Job: build_ios_debug]]

    subgraph Phase 3: Parallel Builds
    E --> E1["🤖 setup_android"]
    E1 --> E2["📦 Build APK"]
    E2 --> E3("📥 Store APK")

    F --> F1[" Install CocoaPods"]
    F1 --> F2["📦 Build iOS App"]
    F2 --> F3("📥 Store IPA")
    end

    %% Styling
    classDef trigger fill:#f9f,stroke:#333;
    classDef phase1 fill:#e6f3ff,stroke:#0066cc;
    classDef phase2 fill:#e6ffe6,stroke:#009900;
    classDef phase3 fill:#fff0e6,stroke:#ff6600;
    classDef tool fill:#fff2cc,stroke:#ffcc00;

    class A trigger;
    class B,B1,B2,B3,B4,B5 phase1;
    class C,C1,C2,C3,C4,C5 phase2;
    class E,E1,E2,E3,F,F1,F2,F3 phase3;
    class D tool;

If the interactive diagram above doesn't work, here's a picture representation showing "Pawfect's" automated travel through our CircleCI Continuous Integration (CI) pipeline.

Conclusion: Automating Confidence, Building for the Future

And just like that, we have wrapped up the most important highlights of quality and automation for "Pawfect.” In this section, we looked at how unit and component tests through Jest and React Native Testing Library gave us rapid feedback and ensured that our code blocks are tested thoroughly.

So, we set this up in our Continuous Integration (CI) pipeline on CircleCI in conjunction with test, linting, and type checking automation. Equally important was Snyk for scanning dependencies for security issues and SonarQube for qualitative code deep analysis—both crucial in helping us maintain a healthy codebase.

Combining these with the clean architecture and streamlined workflow established in Part 1 of this series delivers a truly powerful blueprint. The journey of setting up ‘Pawfect’ makes the case for having a strong thought-out foundation, enabling one to build high-quality maintainable React Native applications with ease and confidence.

I appreciate you joining us in this deep dive. Stay tuned for more insights as we aim towards End-to-End (E2E) testing and beyond.

1
Subscribe to my newsletter

Read articles from Thomas Ejembi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thomas Ejembi
Thomas Ejembi

I'm a dedicated software engineer with a passion for solving business challenges through innovative software solutions. My technical stack spans React, React Native (for both Android and iOS), Kotlin, and Android development—tools I leverage to build dynamic user interfaces and architect efficient, scalable mobile applications that drive business success and elevate user experiences. In my previous role, I led a team of developers to create a high-performance delivery app for a major client in East Asia, as well as a cutting-edge meme coin launchpad. Currently, as a co-founder and mobile engineer at BlinkCore, I help build digital and contactless payment apps that empower users to make blink-fast, secure payments while enabling businesses to receive them seamlessly. Our technology integrates NFC, HCE, QR code scanning, and geolocation to deliver a next-generation payment experience. I thrive on tackling complex problems and consistently deliver scalable, innovative solutions that align with business needs. Let's connect and explore how we can turn challenging ideas into transformative digital experiences.