Dave HunterBlog

Testing TurboModules in Production

I’ve been working with C++ TurboModules for several years now, and one thing I’ve spent a lot of time thinking about is what kind of tooling actually makes this pleasant.

These days I mostly work in VSCode to match what’s common in the React Native world, and I’ve put a fair bit of effort into making that experience decent for native code in a monorepo: shared settings, automatic clang-format, preconfigured build tasks, launch configs for native debugging, and a solid extensions.json so new engineers can get productive quickly.

All of that helps a lot. But there were two big, persistent pain points:

Both of these are tightly coupled to one thing: having a fast, native build you can actually iterate on. That’s what eventually pushed me to build a small component that I now use for both.

If you want to jump straight to the code:


A quick refresher: how TurboModules fit together

Before getting into testing strategies, it’s worth doing a quick, high-level recap of how a React Native TurboModule is structured, because it explains where the pain points usually are and what we’re actually testing.

Conceptually, a TurboModule has three layers:

At runtime, the React Native app talks to the JS spec, the React Native runtime uses the generated glue to cross the boundary, and calls end up in your C++ TurboModule, which usually forwards most of the work into a more conventional C++ layer.

In practice, some treat the TurboModule class itself as “just glue” and only unit test the underlying C++ logic. That’s often reasonable, but as modules grow more complex, more and more important behavior tends to live right at that boundary layer: argument marshaling, lifetime management, threading decisions, error handling, and so on.

That boundary is exactly what makes TurboModules powerful—and it’s also what makes them awkward to test well. The rest of this post is about how I ended up building a workflow that makes testing that layer fast, repeatable, and pleasant to work with.

Evolving approaches

Like most people, I didn’t start with anything fancy. I just wanted something that worked.

GoogleTest for “internal” logic

Probably the most common pattern: treat the TurboModule itself as glue, don’t test it directly, and instead factor the real logic into a separate C++ layer that you test with GoogleTest (or whatever framework you like).

This is totally reasonable—and I still do this—but in practice that “glue layer” can get… not so small. Depending on what your module is doing, a lot of important behavior can live right at the boundary.

XCTest + Objective-C++

The first real TurboModule tests I wrote were using XCTest and Objective-C++. The idea was:

Functionally, this worked pretty well. But… a lot of engineers will (understandably) scream in horror when they see Objective-C++. I also really wanted an approach where someone could work on tests and never leave C++.

GoogleTest bridged into XCTest

So I wrote a little harness that bridges GoogleTest into XCTest. Tests are still just normal C++ GoogleTest files, but they run under Xcode’s test runner. Internally, this involved some fun Objective-C hackery to dynamically generate test methods so they show up in the Xcode UI.

Around this time I also figured out how to drive TurboModules from JavaScript, so many of my tests became a mix of C++ and JS, which is actually pretty nice for end-to-end coverage of the JSI boundary.

I still use this approach for modules that depend on iOS-only APIs and really must run in the simulator. If there’s interest, I could do a separate post on that setup.

But there’s a big downside: it’s slow. You’re paying the cost of a full React Native + iOS build just to run tests. That gets old fast.


CMake + Ninja for TurboModules

Eventually I got tired of waiting.

What I really wanted was:

I didn’t want to rely on the React Native macOS port (as cool as it is), because the app I work on doesn’t ship a macOS version. But I noticed something interesting: the iOS hermes-engine pod already includes a macOS framework.

That got me thinking: how hard would it be to just reuse what CocoaPods already downloaded for iOS and build my TurboModules against that on macOS?

That’s the core idea behind this setup.

The approach

At a high level:

    TurboModuleTesting_ConfigureBasedOnApp("${app_path}")
    TurboModuleTesting_AddTurboModuleDependencies(
      "${turbomodule_target}"
      "${turbomodule_name}"
    )

That’s it. Now your TurboModules build on macOS using Ninja.

Even without writing a single test, this is already a huge win: you get a proper compile_commands.json, which you can feed into whatever language server you’re using. Suddenly, code navigation, go-to-definition, and refactors in VSCode actually work across your native code and React Native headers.


Writing tests

On top of that, I built a small helper: TurboModuleTestingEnvironment.

It provides:

In practice, this makes it really easy to write tests that look a lot like “real” usage of your module, but run in milliseconds instead of minutes.

You still get to keep your GoogleTest workflow, you stay in C++, and you can exercise the actual TurboModule boundary instead of only testing a peeled-off “core” layer.

Example

Exampe Turbo Module Spec


import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  concat(array: Array<number>, separator: string): string;
}

export default TurboModuleRegistry.getEnforcing<Spec>(
  'NativeRTNTestableModule'
);

Example C++ Turbo Module Interface


#include <string>
#include <vector>

#include "NativeRTNTestableModuleJSI.h"

namespace facebook::react {

class RTNTestableModule : public NativeRTNTestableModuleCxxSpec<RTNTestableModule> {
 public:
  explicit RTNTestableModule(std::shared_ptr<CallInvoker> jsInvoker);

  std::string concat(
      jsi::Runtime &rt,
      const std::vector<double> &array,
      const std::string &separator);
};

std::shared_ptr<TurboModule> RTNTestableModuleModuleProvider(
    std::shared_ptr<CallInvoker> jsInvoker);

} // namespace facebook::react

Example C++ Test using TurboModuleTesting

#include "RTNTestableModule.h"
#include <TurboModuleTesting.h>
#include <gtest/gtest.h>

class RTNTestableModule_Tests : public ::testing::Test {
protected:
    void SetUp() override
    {
        facebook::react::registerCxxModuleToGlobalModuleMap(
            "RTNTestableModule", [&](std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
                return std::make_shared<facebook::react::RTNTestableModule>(std::move(jsInvoker));
            });

        _env = std::make_unique<TurboModuleTestingEnvironment>();
    }

    void TearDown() override
    {
        _env = nullptr;
    }

    std::unique_ptr<TurboModuleTestingEnvironment> _env;
};

TEST_F(RTNTestableModule_Tests, testCallingTurboModule)
{
    facebook::jsi::Value result = _env->evaluateJavascript(
        "globalThis.__turboModuleProxy('RTNTestableModule').concat([1,2,3,4], '-')");
    EXPECT_TRUE(result.isString());
    std::string concatResult = facebook::react::Bridging<std::string>::fromJs(_env->rt(), result.getString(_env->rt()));
    EXPECT_EQ(concatResult, "1-2-3-4");
}

When you still want XCTest

This setup doesn’t replace XCTest entirely.

If your TurboModule depends on iOS-only APIs, frameworks, or system behavior that only exists in the simulator or on-device, you’ll still want tests that run there. I still use my GoogleTest-via-XCTest harness for exactly those cases.

Think of this CMake/Ninja approach as:

And XCTest as the slower, heavier, but sometimes necessary integration layer.


Wrapping up

For me, this setup has been a big quality-of-life improvement:

If you’re spending a lot of time in C++ TurboModules and feeling friction around testing or tooling, I hope this gives you a useful new option to add to your toolbox.