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:
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} />
}
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 {
}
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
}
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>
}
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,
}
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>
}
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>
}
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:
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.