Skip to main content

Sub-Component Testing

note

The Getting Started page contains information to help you setup your test environment.

This page outlines APIs for testing assertions about the component hierarchy.

Litho's testing APIs are similar to the APIs of AssertJ and Hamcrest. If you have used those two before, the content of this page should be very familiar.

Basic Sub-Component Matching​

To demonstrate the use of the Litho testing APIs, consider the following component that truncates passed text and appends an ellipsis:

@LayoutSpec
class TruncatingComponentSpec {
@OnCreateLayout
static Component onCreateLayout(ComponentContext c, @Prop String text) {
// A unicode-aware implementation is left as an exercise to the reader.
final String s = text.length() > 16 ? text.substring(0, 16) + "..." : text;
return Text.create(c).text(s).build();
}
}
note

For trivial components, it is often more appropriate to exploit the fact that they are pure functions that can be statically invoked. Whenever possible, test your business logic in isolation.

Setup​

  • Add @RunWith(LithoTestRunner.class) to the top of the test class.
  • Add a JUnit @Rule LithoViewRule.
  • Add a check to ensure that tests are run in debug mode. ComponentsConfiguration.IS_INTERNAL_BUILD must be true.

The test class should look like the following:

/**
* Tests {@link LikersComponent}
*/
@RunWith(RobolectricTestRunner.class)
public class TruncatingComponentTest {

public final @Rule LithoViewRule mLithoViewRule = new LithoViewRule();

@Before
public void assumeInDebugMode() {
assumeThat(
"These tests can only be run in debug mode.",
ComponentsConfiguration.IS_INTERNAL_BUILD, is(true)
);
}

}

Testing Assertion on the Component Hierarchy​

@Test
public void whenTextLengthIsLessThan16_shouldContainTextComponentWithFullText() {
final ComponentContext c = mLithoViewRule.getContext();
final TruncatingComponent component = TruncatingComponent.create(c)
.text("Mr. Meeseeks").build();

LegacyLithoAssertions.assertThat(c, component)
.has(subComponentWith(c, textEquals("Mr. Meeseeks")));
}

Understanding the API Usage​

  • LegacyLithoAssertions.assertThat(ComponentContext, Component) creates and mounts the layout.
  • has(ComponentContext, Condition) tests if the component hierarchy passes the assertion of the Condition. This is a standard AssertJ API; to see all AssertJ APIs, refer to the AssertJ Core Conditions website.
  • subComponentWith(ComponentContext, Condition) is a utility method from Litho's testing APIs to compose Conditions together.
  • textEquals(String) is another utility method that creates a Condition that passes only for a Component of type Text, which has its text property set to the String argument.
  • subComponentWith(c, textEquals("Mr. Meeseeks") creates a Condition that 'passes for a component of type Text with its text property set to "Mr. Meeseeks"'.

The following code is a more complex composition of similar assertions:

LegacyLithoAssertions.assertThat(c, component)
.has(allOf(
subComponentWith(c, textEquals("Mr. Meeseeks")),
subComponentWith(c, text(startsWith("Mr."))),
subComponentWith(c, anyOf(
text(endsWith("Sanchez")),
text(containsString("Mees")))
)
));
note

The above assertions are only for the purpose of illustration. This is not a good test!

Matching Against Complex Hierarchies​

Consider the following more complex LayoutSpec. It still has the same text truncation logic, with some new UI elements, and wraps the Text in a Column and a Card.

@LayoutSpec
public class TruncatingComponentSpec {
@OnCreateLayout
public static Component onCreateLayout(ComponentContext c, @Prop String text) {
final String s = text.length() > 16 ? text.substring(0, 16) + "..." : text;
return Column.create(c)
.backgroundColor(Color.GRAY)
.child(Card.create(c).content(Text.create(c).text(s)))
.build();
}
}

The original test will start failing now:

@Test
public void whenTextLengthIsLessThan16_shouldContainTextComponentWithFullText() {
final ComponentContext c = mLithoViewRule.getContext();
final TruncatingComponent component = TruncatingComponent.create(c).text("Mr. Meeseeks").build();

LegacyLithoAssertions.assertThat(c, component)
.has(subComponentWith(c, textEquals("Mr. Meeseeks")));
}

The error messages should provide sufficient information to understand why the test failed. The error message prints out the component hierarchy, and the assertion that failed:

Expecting:
<TruncatingComponent{0, 0 - 100, 100}
Card{0, 0 - 100, 6}
Column{3, 2 - 97, 2}
Text{0, 0 - 94, 0 text="Szechuan Sauce"}
CardClip{0, 0 - 94, 0}
CardShadow{0, 0 - 100, 6}>
to have:
<sub component with <text <is "Szechuan Sauce">>>

Here, the Text component was expected to be a direct descendant of TruncatingComponent. However, the error message shows that the Text component is several levels below the TruncatingComponent.

This test can be fixed by using a different Condition API called deepSubComponentWith. As the name suggests, this condition will test against all the components in the hierarchy, and not just the immediate descendant.

@Test
public void whenTextLengthIsLessThan16_shouldContainTextComponentWithFullText() {
final ComponentContext c = mLithoViewRule.getContext();
final TruncatingComponent component = TruncatingComponent.create(c).text("Mr. Meeseeks").build();

LegacyLithoAssertions.assertThat(c, component)
.has(
deepSubComponentWith(c, textEquals("Mr. Meeseeks"))
);
}
info

For information on all Component conditions, see the ComponentConditions JavaDoc.

Custom Conditions​

Custom Conditions can be created by implementing the Condition<InspectableComponent> interface which consists of a single method: matches(InspectableComponent).

InspectableComponent is a wrapper around a Component with additional information about the component.

Creating a Custom Condition​

public static Condition<InspectableComponent> hasBackground() {
return new Condition<InspectableComponent>() {
@Override
public boolean matches(InspectableComponent value) {
as("any background"); // error message.
return value.getBackground() != null; // value contains the component being tested.
}
};
}

Using a Custom Condition​

@Test
public void whenTruncatingComponentIsRendered_shouldHaveBackground() {
final ComponentContext c = mLithoViewRule.getContext();
final TruncatingComponent component = TruncatingComponent.create(c).text("Mr. Meeseeks").build();

LegacyLithoAssertions.assertThat(c, component)
.has(deepSubComponentWith(c, hasBackground()));
}

Simple Sub-Component Matching​

To test for the mere presence of a component of a certain type, use the SubComponent.of API.

Consider a hypothetical LayoutSpec called StoryComponentSpec, which consists of a HeaderComponentSpec, MessageComponentSpec, LikersComponentSpec, and a FeedbackComponentSpec.

The following test can be used to assert the presence of those components in the hierarchy:

public class StoryComponentTest {
...

@Test
public void whenStoryComponentIsRendered_shouldContainAllSubcomponents() {
final ComponentContext c = mLithoViewRule.getContext();
final StoryComponent.Builder builder = StoryComponent.create(c).build();

assertThat(builder).hasSubComponents(
SubComponent.of(HeaderComponent.class),
SubComponent.of(MessageComponent.class),
SubComponent.of(LikersComponent.class),
SubComponent.of(FeedbackComponent.class));

assertThat(builder)
.doesNotContainSubComponent(SubComponent.of(TruncatingComponent.class));
}
}

Using with Legacy APIs​

Litho provides a bridge interface legacySubComponent that enables use of the SubComponent.of API with the subComponentWith APIs. It accepts a SubComponent and works with both shallow and deep sub-component traversals. This is ideal if you want to ensure that a component with a given set of props exists in the component tree.

@Test
public void testSubComponentLegacyBridge() {
final ComponentContext c = mLithoViewRule.getContext();

assertThat(c, mComponent)
.has(
subComponentWith(
c,
legacySubComponent(
SubComponent.of(
FooterComponent.create(c).subtitle("Gubba nub nub doo rah kah").build()))));
}

Resources​

To learn more, see the following resources: