Skip to main content

Matching @Prop

Tips

For help with setting up the Test environment, see the Getting Started page.

Before learning about @Prop matching, it's recommended you become familiar with sub-component testing.

Within this page, you'll explore TestSpecs to test individual props of Components, even if you don't know all of them.

Complex Components​

Composability is one of Litho's greatest strengths. It enables you to encapsulate your logic in small components and combine them effortlessly into larger ones. But, despite all good efforts, there is sometimes no clear dividing line and your component may grow beyond its original scope.

Having complex components shouldn't prevent you from using them confidently. A set of powerful APIs give you the ability to test your components no matter what their size or complexity.

Consider the following LayoutSpec 'ComplexComponentSpec':

@LayoutSpec
public class ComplexComponentSpec {
@OnCreateLayout
static Component onCreateLayout(
ComponentContext c,
@Prop StoryProps<ComplexAttachment> storyProps,
@Prop ImageRequest imageRequest,
@Prop DraweeController draweeController,
@Prop String title,
@Prop(resType = ResType.DIMEN_TEXT) int titleTextSize,
@Prop int visiblePhotoCount,
@Prop(optional = true) Artist favoriteArtist,
@Prop(optional = true) boolean shouldHavePuppies) {
return Row.create(c).build();
}
}

Testing Complex Components​

Within the props of the ComplexComponentSpec, there are a a lot of opaque objects that we may have trouble getting hold of for our tests. StoryProps might be something we obtain through some dependency injection mechanism. A DraweeController is an implementation detail we shouldn't have to worry about for ensuring that the component tree has the right shape. However, if you recall from the SubComponent.of API, it's necessary to specify all non-optional props for it to succeed.


To carry out the testing, you create a standard JUnit test suite and run it with a RobolectricTestRunner-compatible implementation like LithoTestRunner.

As shown in the diagram to the right, assume that a FeedItemComponent contains the ComplexComponent specified above. The FeedItemComponent contains the logic for populating our complex props which we want to verify, as shown in the following code:



@Test
public void testComplexSubComponent() {
final ComponentContext c = mLithoViewRule.getContext();
final Component<FeedItemComponent> component = makeComponent("Two Brothers");

assertThat(c, component)
.has(
subComponentWith(
c, legacySubComponent(SubComponent.of(
// ERROR: This fails at runtime as we haven't provided all
// required parameters.
ComplexComponent.create(c)
.title("Two Brothers")
.build()))));
}

Sadly, this test fails with the following error message:

java.lang.IllegalStateException: The following props are not marked as optional and were not supplied: [storyProps, imageRequest, draweeController,titleTextSize, visiblePhotoCount]

If it's not possible to provide these props in your tests, or if you we don't want to test implementation details like the image loading controller, you could simply choose not to test any props at all and decide to verify only the presence of your component:

@Test
public void testComplexSpecIsPresent() {
final ComponentContext c = mLithoViewRule.getContext();
final Component<FeedItemComponent> component = makeComponent("Rixty Minutes");

assertThat(c, component)
.has(
subComponentWith(
c, inspectedTypeIs(ComplexComponent.class)));
}

While this type of testing is not perfect, it's better than nothing.

Props Matching with @TestSpec​

TestSpecs enable you to exactly match against those props that you want to test. Just as with LayoutSpecs and MountSpecs, TestSpecs make use of the powerful annotation processing mechanism Java offers and generate code for you.

To start your testing project, create a new class and link it the original spec for which you want to generate the TestSpec:

@TestSpec(ComplexComponentSpec.class)
public interface TestComplexComponentSpec {}

The above two lines are enough to generate a powerful 'matcher' that can be used in your tests.

There are a few items of note:

  • The class you reference in @TestSpec must be a LayoutSpec or MountSpec.
  • You must link to the Spec and not the generated component, for example, ComplexComponentSpec.class not ComplexComponent.class.
  • In contrast to other specs, TestSpecs are generated from an interface, not a class.
  • The interface must be empty: it cannot have any members.
  • By convention, you prefix your TestSpec with Test, followed by the original spec name.

Now that the TestSpec is created, it's time to put it to use.

Using @TestSpec​

Where normal components have a create function, test specs come with a matcher function. It does take the same props as the underlying component but enable you to omit non-optional props, as shown in the following code:

@Test
public void testComplexTestSpecProps() {
final ComponentContext c = mLithoViewRule.getContext();
final Component<FeedItemComponent> component = makeComponent("Two Brothers");

assertThat(c, component)
.has(
subComponentWith(
c, TestComplexComponent.matcher(c)
.shouldHavePuppies(false)
.build()));
}

This omission of puppies (.shouldHavePuppies(false)) couldn't possibly pass the test run. It will fail with the following 'helpful' error message:

java.lang.AssertionError:
Expecting:
<FeedItemComponent{0, 0 - 100, 100}
ComplexComponent{0, 0 - 100, 0}
Column{0, 0 - 100, 50}
FeedImageComponent{0, 0 - 100, 50}
RecyclerCollectionComponent{0, 0 - 100, 50}
Recycler{0, 0 - 100, 0}
TitleComponent{4, 46 - 16, 46}
Text{4, 46 - 16, 46 text="Some Name"}
ActionsComponent{60, 4 - 96, 40}
FavouriteButton{2, 2 - 34, 34 [clickable]}
FooterComponent{0, 50 - 100, 66}
Text{8, 8 - 92, 8 text="Two Brothers"}>
to have:
<sub component with <Sub-component of type <ComplexComponent> with prop <shouldHavePuppies> is <false> (doesn't match true)>>

From the error message, you can see a brief overview of the hierarchy you were matching against and the matcher that failed.

Advanced Matchers​

Instead of just matching against partial props, you can also provide 'hamcrest' matchers in any place that accepts concrete values. For props that take resource types, you can make use of all the same matchers you find in regular components:

@Test
public void testComplexTestSpecAdvancedProps() {
final ComponentContext c = mLithoViewRule.getContext();
final Component<FeedItemComponent> component =
makeComponent("Rixty Minutes");

assertThat(c, component)
.has(
subComponentWith(
c, TestComplexComponent.matcher(c)
// titleTextSizeDip, Sp etc. work too!
.titleTextSizeRes(R.dimen.notification_subtext_size)
.title(containsString("Minutes"))
.build()));
}

Matching Matchers​

There is one type of prop that requires some special treatment: a Component.

While you could just match against child components via normal equality (there is support for this), it is not particularly helpful. Rarely is it known what exact instance of a component is passed down to the props and you'll face many of the same problems discussed above: the props of the Component may not be known in full or perhaps you don't want to provide them all.

The solution to these problems is to match matchers!

For any prop that takes a Component, the TestSpec generates a matcher that takes another matcher. This allows for declarative matching against entire trees of components.

Continuing with the given example, suppose that the FeedItemComponent wraps the ComplexComponent in a Card, as shown in the following code:

@Test
public void testComplexTestSpecProps() {
final ComponentContext c = mLithoViewRule.getContext();
final Component<FeedItemComponent> component = makeComponent("Ricksy Business");

assertThat(c, component)
.has(
subComponentWith(
c, TestCard.matcher(c)
.content(TestComplexComponent.matcher(c)
.title(endsWith("Business"))
.build())
.build()));
}

Notice the TestCard used to declare the hierarchy; the litho-testing package comes with TestSpecs for all standard Litho widgets.

A Note on Buck​

If you use Gradle, it should 'just work' and shouldn't require any additional setup.

With Buck or Blaze/Bazel, you may need some additional configuration for the annotation processing step to work.

In order to save your copy-pasting boilerplate all over your project, it is recommended to keep a rule definition like this in a well-known location (such as //tools/build_defs/android/litho_testspec.bzl). You would obviously have to adjust the library paths to the corresponding targets in your code base.

"""Provides macros for working with litho testspec."""

def litho_testspec(
name,
deps=None,
annotation_processors=None,
annotation_processor_deps=None,
**kwargs
):
"""Litho testspec."""
deps = deps or []
annotation_processors = annotation_processors or []
annotation_processor_deps = annotation_processor_deps or []

deps.extend(
[
"//java/com/facebook/litho:litho",
"//third-party/android/androidx:support-v4",
"//libraries/components/litho-testing/src/main/java/com/facebook/litho/testing:testing",
"//libraries/components/litho-testing/src/main/java/com/facebook/litho/testing/assertj:assertj",
"//third-party/java/jsr-305:jsr-305",
"//third-party/java/hamcrest:hamcrest",
]
)

annotation_processor_deps.extend(
[
"//libraries/components/litho-processor/src/main/java/com/facebook/litho/specmodels/processor:processor-lib"
]
)

annotation_processors.extend(
[
"com.facebook.litho.specmodels.processor.testing.ComponentsTestingProcessor",
]
)

return android_library(
name,
deps=deps,
annotation_processors=annotation_processors,
annotation_processor_deps=annotation_processor_deps,
**kwargs
)

In the definitions for your test suite, you can then create a separate target for your test specs:

load("//buck_imports:litho_testspec.bzl", "litho_testspec")

litho_testspec(
name = "testspecs",
srcs = glob(["*Spec.java"]),
deps = [
"//my/library/dependencies",
# ...
],
)

robolectric_test(
name = "test",
srcs = glob(["*Test.java*"]),
deps = [
":testspecs",
# ...
]
)

This ensures that test specs are processed by the dedicated ComponentsTestingProcessor.

TL;DR​

Step 1 - Create a TestSpec for your LayoutSpec or MountSpec.

@TestSpec(MyLayoutSpec.class)
public interface TestMyLayoutSpec {}

Step 2 - Use the generated test matcher in your suite.

@Test
public void testComplexTestSpecAdvancedProps() {
final ComponentContext c = mLithoViewRule.getContext();
final Component<MyWrapperComponent> component = ...;

assertThat(c, component)
.has(
subComponentWith(
c, TestMyLayout.matcher(c)
.titleTextSizeRes(R.dimen.notification_subtext_size)
.title(containsString("Minutes"))
.child(TestChildComponent.matcher(c).size(greaterThan(5)).build())
.build()));
}