Skip to main content

Sub-Component Testing

note

Checkout the getting started section to setup the test environment correctly.

This document 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 the two before, this should feel very familiar. This guide will describe the APIs and its usage.

Basic Sub-Component Matching#

To demonstrate the usage of these APIs consider the following component that truncates the text passed and appends 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

As a quick side note; for trivial components like these it is often more appropriate to exploit the fact that these 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();
LithoAssertions.assertThat(c, component)
.has(subComponentWith(c, textEquals("Mr. Meeseeks")));
}

Understanding the API usage#

  • LithoAssertions.assertThat(ComponentContext, Component) will create and mount the layout.
  • has(ComponentContext, Condition) tests if the component hierarchy passes the assertion of the Condition. This is a standard AssertJ API; checkout all the default APIs here.
  • subComponentWith(ComponentContext, Condition) is a utility method from Litho's testing APIs to compose Conditions together.
  • textEquals(String) is another utility method which 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") create a Condition which is exactly what it reads like; 'passes for a component of type Text with its text property set to "Mr. Meeseeks"'.

The following is a more complex composition of similar assertions

LithoAssertions.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 just for illustrative purposes. This is not a good test!

Matching against complex hierarchies#

Consider a more complex LayoutSpec; it still has the same text truncation logic, but with some new UI elements; wrapping 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();
LithoAssertions.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, but 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();
LithoAssertions.assertThat(c, component)
.has(
deepSubComponentWith(c, textEquals("Mr. Meeseeks"))
);
}
info

Find all the Component conditions in the JavaDoc here

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();
LithoAssertions.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 composes a HeaderComponentSpec, a MessageComponentSpec, a LikersComponentSpec, and a FeedbackComponentSpec. The following test can be used to assert the presence of these 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 which enables using the SubComponent.of API with the subComponentWith APIs. It accepts a SubComponent and works with both shallow and deep sub-component traversals. This is great 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 = mComponentsRule.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, check out these resources: