A System For Automatically Adding TestIds To React Code

Felicia Walker - Oct 11 '23 - - Dev Community

While at OfferUp I had to create automation using Detox and Appium for a React native app. To ensure we could reliably find UI elements, it was decided to use the data-testid HTML attribute.

React has its own specific one for the purpose of identifying elements during automation named testID that gets rendered in HTML data-testid. It does not interfere with any other React functionality. TestIDs should have unique values to avoid fragility and simplify test code.

The Problem And The Solution

Usually testIDs are coded in by developers when adding markup in their React code. However, this is unreliable since people forget to do it, assign too many or too few, can create duplicate values, and use inconsistent naming schemes. This makes it difficult to write tests when you are not sure what to look for or if it exists at all. Adding/fixing testIDs while writing tests is inefficient and painful, and leads to a giant mess.

It would be nice if there was a system in place to automatically assign testIDs in the markup of the React components. Since a React app is typically a hierarchy of custom components, it should be possible to assigned unique values to each component, concatenate these as child components are added, then use the final value as the testID of a React tag.

This solution would also fix another wrinkle. Sometimes Detox/Appium cannot access the data-testid attribute for whatever reason. In this case, the accessibilityLabel attribute can be used instead. In order for this to work, we need to defined it as well in the product code with the same value as the React testID.

Implementation

TestIDs are generally constructed by defining a top level base string at a root component, passing it to children, then having them append to it. At the lowest level, where the actual React tags are used, the value is assigned to the testID and accessibilityLabel attributes.

Example overview

For this article we will lay out things like this for a React native app:

Diagram of the example component layout

There are our own components which can contain other custom components or React ones. There is a component library (CL) at the low level which define our custom version of UI primitives (ie: buttons, text, etc...). These can use one or more React components.

Assigning our testIDs is mostly done at the CL level, but can also be done at higher levels where View or Touchable are used. Constructing the IDs, however, happens throughout the hierarchy.

Top level components

At the top we have a screen container. The relevant code should look similar to this:

export const MyScreen: FC = () => {
  const topLevelTestID = 'my-screen'}

  // A CL component, so add a descriptor to the id
  <MyCLButton baseTestID={topLevelTestID + '.do-not-press'}>Do not press</MyCLButton> 

  // An intermediate component, so just pass the base and it will append more specific info to the id
  <ChildThing baseTestID={topLevelTestID} />  
}
Enter fullscreen mode Exit fullscreen mode

For ease of use, we define a top level testID base string, topLevelTestID, which will have additional id segments appended to it. This modified string is passed to lower components via their baseTestID attributes.

The name "baseTestID" was used to indicate that the id is not final and to not be confused with React's testID. This attribute is enforced on children via an interface.

If the child component is a CL one, a suffix based on the type, such as ".button" or ".text" will be appended inside the CL class. Therefore, all we need to provide here is a more specific descriptor for the component. The code above will end up assigning my-screen.do-not-press.button to the CL button.

If the component is an intermediate one, we just pass the top level id as a base. This component will then append its own specific identifying info and repeat the process.

Intermediate level components

There are a few things we need for the ChildThing component. The first is an interface to define its properties:

interface ChildThingProps extends BaseTestable {
}
Enter fullscreen mode Exit fullscreen mode

We want it to have the baseTestId attribute and the best way to that is with an interface that can be applied to all of our child components:

interface BaseTestable {
  baseTestId: string
}
Enter fullscreen mode Exit fullscreen mode

Using this interface consistently helps enforce the testIDs and also labels components that use this system.

Now our component itself:

export const ChildThing: FC<ChildThingProps> = ({ baseTestID }) => {

  // Assign a default baseID in case one was not passed in
  const localTestID = (baseTestID ?? 'default') + '.child-thing'

  // A React component so we need to assign the testID and accessibilityLabels directly
  <View testID={localTestID} accessibilityLabel={localTestID}>  
    <ChildThing baseTestID={localTestID} /> 
    <MyContainer testID={localTestID + '.actions'}>
      <MyCLButton baseTestID={localTestID + '.actions.donuts'}>Donuts, please</MyCLButton> 
    </MyContainer>
  </View>   
}
Enter fullscreen mode Exit fullscreen mode

The component first gets the passed in base id and appends a string that describes the component. This is stored in a localTestID variable, which serves as the new base. We also take care to assign a default value in case a baseTestID was not passed in.

There is a React component is here, and we need to assign the testID and accessibilityLabel attributes directly. For most other components, this happens in the CL class which wraps React primitives.

You can also see how there can be components which have a testID attribute instead of a baseTestID one. In this case, the component just uses the passed id as is. The attribute name “testID” is used to connote that it is a final value and will not be appended to. This is mostly used for container components, since we mostly don’t care what type of container it is, so we don’t need to specify any additional information.

Low level components

Finally we have the lowest level components: MyCLButton and MyContainer. MyCLButton is part of our component library, which are leaf nodes in our UI tree. MyContainer is not a leaf node, but is considered a primitive.

MyCLButton will use the baseTestId property since it will append the control type to it:

interface MyCLButtonProps extends BaseTestable {
  buttonText: string,
}
Enter fullscreen mode Exit fullscreen mode

Also like the mid level components it will append information to the testID (here, the control type), but then assign that directly to the testID and accessibilityLabel attributes on the React tags used:

export const MyCLButton: FC<MyCLButtonProps> = ({ buttonText, baseTestID }) => {

  const localTestID = (baseTestID ?? 'cl') + '.button' 

    // React components so we need to assign the testID and accessibilityLabels directly
    <Touchable testID={localTestID} accessibilityLabel={localTestID}>  
      <Text testID={localTestID + '.text'} accessibilityLabel={localTestID + '.text'}>{buttonText}</Text>  
    </Touchable>   
}
Enter fullscreen mode Exit fullscreen mode

MyContainer is very similar, but uses testID instead of baseTestID as its property. This is enforced by having MyContainerProps extend FinalTestable instead of BaseTestable:

interface MyContainerProps extends FinalTestable {}

export const MyContainer: FC<FinalTestable> = ({ children, testID }) => {  

    // A React component so we need to assign the testID and accessibilityLabels directly
    <View testID={testID} accessibilityLabel={testID}>  
      {children}
    </View>    
}
Enter fullscreen mode Exit fullscreen mode

Like the View in the previous section,MyContainer is a React tag and needs a final testID. The child controls will already have their testID information, so they just get passed along.

It is helpful to print out the final testID value in the CL components right before using the React tags. This helps the development of UI tests since you know exactly what is on a screen and what the names are.

A lot of info can be dumped, but you can always send it through a custom logger with options to clean it up.

Conclusion

Here is a diagram of the testID values as they exist in our UI hierarchy:

Tree diagram of final testID values from the example

You can see how a top level base testID is defined and passed down to children, which append their own information and pass that on. Containers do not append their own information since it add noise to its children. Finally, leaf components append their own details about the control type and assign the values directly to the React tag's testID and accessibilityLabel.

This system works pretty well, but can be tricky to use with components like navigation bars and modal dialogs because of their complexity. However, it is not too difficult to implement, creates unique, descriptive names, and reduces the burden on everyone to generate and keep track of testIDs.

. . . . . . .