— React Native, Typescript, Jest — 4 min read
So I've been slowly converting react-native-bluetooth-classic to Typescript, which went off fairly well, and decided it would be a good idea to start adding tests. Figured it would get me some experience working with Jest and getting a better product available for everyone.
I think I've made a huge mistake!!
Although I don't actually regret the decision, it's defintely been more of a process than I had originally anticipated. This post isn't going to be anything original, it's just going to be the process that was required in order to get things working.
Following the treasure trail of internet posts documenting how to get Typescript, Jest and React Native working together, it was apparent that it would be a little bit of work, but generally fairly straight forward. The consensus is that the following libraries are required:
First thing first, lets follow ts-jest/install and get all our ducks in a row. First we'll install all the
1npm install --save-dev jest ts-jest @types/jest
Once we've got things installed, we can get to configuring, here's another instance of following the internet treasure trail. We take a look at the available documentation on ts-jest there is a page specific to ts-config/react-native. The first thing we come to when we get there, is that we should check out another post on react-native Typescript.
Using TypeScript with React Native
Jumping down the page the first section of importance is adding typescript, the key parts being the added typescript transformer and the react native config file:
1npm install --save-dev react-native-typescript-transformer2npm install --save-dev @types/jest @types/react @types/react-native @types/react-test-renderer3touch rn-cli.config.js
uncommenting the appropriate lines in tsconfig.json
:
1{2 /* Search the config file for the following line and uncomment it. */3 "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */4}
and finally adding the rn-cli.config.js
configuration:
1// rn-cli.config.js2module.exports = {3 getTransformModulePath() {4 return require.resolve('react-native-typescript-transformer');5 },6 getSourceExts() {7 return ['ts', 'tsx'];8 },9};
The next key spot is adding typescript testing infrastrucure where it says to add the configuration to package.json
, which clearly went against most of the other configuration places. After trying both ways, I can confirm that having the Jest configuration inside package.json
does not work.
So we'll just stick with the same content in jest.config.js
:
1{2 "jest": {3 "preset": "react-native",4 "moduleFileExtensions": [5 "ts",6 "tsx",7 "js"8 ],9 "transform": {10 "^.+\\.(js)$": "<rootDir>/node_modules/babel-jest",11 "\\.(ts|tsx)$": "<rootDir>/node_modules/ts-jest/preprocessor.js"12 },13 "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",14 "testPathIgnorePatterns": [15 "\\.snap$",16 "<rootDir>/node_modules/"17 ],18 "cacheDirectory": ".jest/cache"19 }20}
So this is where the first issue happens, because this is a library it has react
and react-native
set as peer dependencies.
1> react-native-bluetooth-classic@1.0.0-rc.2 test /Users/kendavidson/git/react-native-bluetooth-classic2> jest3
4● Validation Error:5
6 Preset react-native is invalid:7
8 The "id" argument must be of type string. Received type object9 TypeError [ERR_INVALID_ARG_TYPE]: The "id" argument must be of type string. Received type object
Installing react
and react-native
(following the lowest version requirements in the peer dependencies) as dev dependencies cleared this issue up.
Now that we've followed that doc, we can go back to the ts-config/react-native page and continue to follow along. First we need to create the Babel configuration:
1// babel.config.js2module.exports = {3 presets: ['module:metro-react-native-babel-preset'],4};
and then we need to modify the Jest configuration:
1// jest.config.js2const { defaults: tsjPreset } = require('ts-jest/presets');3
4module.exports = {5 ...tsjPreset,6 preset: 'react-native',7 transform: {8 ...tsjPreset.transform,9 '\\.js$': '<rootDir>/node_modules/react-native/jest/preprocessor.js',10 },11 globals: {12 'ts-jest': {13 babelConfig: true,14 },15 },16 // This is the only part which you can keep17 // from the above linked tutorial's config:18 cacheDirectory: '.jest/cache',19};
Which essentially rewrites the original file, pretty much we're just adding the react-native/jest/preprocessor.js
so that the imported React Native modules are compiled correctly during testing.
The first thing that I wanted to do, was move the validation of IOS and Android logic to Javascript, instead of bothing to call IOS Native for things that don't exist. To do this it made sense to test things based on Platform
(mocking Platform):
1/// __tests__/BluetoothClassicModule.ios.test.js2import { Platform } from 'react-native';3
4// No dice5jest.mock('Platform', () => {6 ...7});8
9// No dice10jest.mock('/react-native/Libraries/Utilities/Platform', () => {11 ...12});
This one took a bunch of Googling. While attempting to mock the Platform
module directly, would continually throw errors that Platform could not be found in /react-native/Libraries/Utlities/react-native-implementation.js
. The key here is https://github.com/facebook/react-native/issues/26579#issuecomment-535244001 which explains:
This is intentional and you need to mock modules the same way as any other JS module now. You could in theory specify the path to the TextInput module, but the path is a private implementation detail that could change between releases. The logically correct approach is to code to the interface and mock out react-native.
which makes complete sense. The resulting mock becomes:
1/// __tests__/BluetoothClassicModule.ios.test.js2import { Platform } from 'react-native';3
4jest.mock('react-native', () => ({5 Platform: { OS: 'ios' },6}));7
8describe('React Native Platform', () => {9 test("Platform.OS should be 'ios'", () => {10 expect(Platform.OS).toBe('ios');11 });12});
which finally results in a successful test:
1> react-native-bluetooth-classic@1.0.0-rc.2 test /Users/kendavidson/git/react-native-bluetooth-classic2> jest3
4 PASS __tests__/BluetoothModule.ios.test.ts5 React Native Platform6 ✓ Platform.OS should be 'ios' (2 ms)7
8Test Suites: 1 passed, 1 total9Tests: 1 passed, 1 total10Snapshots: 0 total11Time: 2.03 s, estimated 7 s12Ran all test suites.
And just as I thought, you take one step forward and two steps back. Since I'm working on a library I want to be able to test said library without having to push changes to Git/Npm in order to test. For this reason I have the following structure:
1|- react-native-bluetooth-classic2 |- BluetoothClassciExample3 | |- node_modules4 |- node_modules
and I run react-native start
out of BluetoothClassciExample
. To get this working I have a customized metro.config.js
which pulls in my react-native-bluetooth-classic
module from ../
. The sad thing here is that I'm unaware of a way to make it NOT use the now required local node_modules dependency of react-native
.
Now because react-native
is being loaded twice:
1┌──────────────────────────────────────────────────────────────────────────────┐2│ │3│ Running Metro Bundler on port 8081. │4│ │5│ Keep Metro running while developing on any JS projects. Feel free to │6│ close this tab and run your own Metro instance if you prefer. │7│ │8│ https://github.com/facebook/react-native │9│ │10└──────────────────────────────────────────────────────────────────────────────┘11
12Looking for JS files in13 /Users/kendavidson/git/react-native-bluetooth-classic/BluetoothClassicExample14 /Users/kendavidson/git/react-native-bluetooth-classic
There are a large number of conflicts and issues which which cause a bunch of crazy errors:
which all seem to point to things like hot reloading, dev mode, resetting cache, adb reverse, etc. None of which work, so sadly at this point it looks like I either get:
and at this point I'm going with option 2. Sorry testing!
After some late night and early morning Googling, I came across a customization of Metro that seems to fit better with my example https://medium.com/@charpeni/setting-up-an-example-app-for-your-react-native-library-d940c5cf31e4 which makes the following changes:
resolver.extraNodeModules
with watchFolders
in the metro configuration.resolver.blacklist
Which doesn't even exist on the Metro config where it's called resolver.blockList
. The issue here is that it specifically says:
A RegEx defining which paths to ignore, however if a blocklisted file is required it will be brought into the dependency graph.
But it's worth a shot!!
After following the previous posts information, there are still errors that revolve around lib/node_modules/react-native
being installed. When attempting to run, it's still attempting to load the react-native
from the ../node_modules
lib folder instead of from the /example/node_modules/
folder causing all those wonderful duplicate React Native issues.
So at this point, I can write my tests with react
and react-native
installed, then uninstall them when I want to start doing more live testing within the BluetoothClassicExample app.
Since this seems to work for this post (and others) I'm starting to think that the issue is the introduction of Typescript during the build process. But at this point I'd rather keep Typescript (and deal with the [un]installing) rather than go back to plain JavaScript or do the half way kludge of babel-typescript
at this point.
This edit was a little late, but things are finally working. Using the same metro-config
above, but changing the strcuture of the projects, I'm able to finally:
react
and react-native
devDependencies for testingusing the following structure:
1|- git2 |- react-native-bluetooth-classic3 |- react-native-bluetooth-classic-apps4 |- BluetoothClassicExample
I know it might be a little much, but I chose the extra layer in case I needed to provide sample apps for things like: different versions, bug fixes, etc. This also cleans up the react-native-bluetooth-classic
project in that only tests are required.
Now I just have to write them :(