Public Transit is a framework for integration tests that models application states and transitions between them.
See the Getting Started with Public Transit guide.
See some example tests:
Scalability
Public Transit introduces a framework for sharing code between tests, such that UI affordances are modelled once in the Transit Layer, and then reused between tests. When app behavior changes, the matching test changes will often be limited to the Transit Layer (instead of individual tests). Furthermore, when systemic issues are found, fixes for them can often be made in the Framework Layer without the need to update individual tests.
Debuggability
Chrome‘s integration test failures have historically been difficult to diagnose, especially when not reproducible with local debugging. Public Transit’s extensive logging and assertions provides more upfront information about test failures.
Consistency
Integration tests are complex, and often so is the code behind them. Public Transit's clear distinction between “Transit Layer” and “Test Layer” guides tests to be written in a consistent way, making them easier to understand.
Discoverability
The Transit Layer makes heavy use of concrete types, such that all transitions between states are discoverable through auto-complete:
Public Transit emits detailed logs to Android's Logcat for each transition and active condition. When transitions fail (due to timeouts), the state of all conditions are logged. This step-by-step logging helps contextualizing failures like native crashes and allows comparing failing runs with successful runs.
Example Logs Output:
Conditions fulfilled:
[1] [ENTER] [OK] View: (with id: org.chromium.chrome.tests:id/tab_switcher_button and is displayed on the screen to the user) (fulfilled after 0-25 ms)
[3] [ENTER] [OK] View: (with id: org.chromium.chrome.tests:id/menu_button and is displayed on the screen to the user) (fulfilled after 0-33 ms)
...
[9] [ENTER] [OK] URL of activity tab contains "/chrome/test/data/android/navigate/two.html (fulfilled after 5401-5671 ms)
34- 2908ms ( 48x): NO | ActivityTab url: "http://127.0.0.1:33010/chrome/test/data/android/popup_test.html"
3073- 5401ms ( 41x): NO | ActivityTab url: "http://127.0.0.1:33010/chrome/test/data/android/navigate/one.html"
5671- 5726ms ( 2x): OK | ActivityTab url: "http://127.0.0.1:33010/chrome/test/data/android/navigate/two.html"
Trip 4: Arrived at <S4: PageStation>
...
org.chromium.base.test.transit.TravelException: Did not complete Trip 11 (<S9: WebPageStation> to <S10: WebPageStation>)
at org.chromium.base.test.transit.Transition.newTransitionException(Transition.java:164)
at org.chromium.base.test.transit.Transition.waitUntilConditionsFulfilled(Transition.java:140)
at org.chromium.base.test.transit.Transition.performTransitionWithRetries(Transition.java:95)
at org.chromium.base.test.transit.Transition.transitionSync(Transition.java:55)
at org.chromium.base.test.transit.Station.travelToSync(Station.java:102)
at org.chromium.chrome.test.transit.page.PageStation.loadPageProgrammatically(PageStation.java:358)
at org.chromium.chrome.test.transit.testhtmls.PopupOnLoadPageStation.loadInCurrentTabExpectPopups(PopupOnLoadPageStation.java:70)
at org.chromium.chrome.browser.PopupPTTest.test900PopupWindowsAppearWhenAllowed(PopupPTTest.java:130)
... 47 trimmed
Caused by: org.chromium.base.test.util.CriteriaNotSatisfiedException: Did not meet all conditions:
[1] [ENTER] [OK ] Activity exists and is RESUMED: ChromeTabbedActivity {fulfilled after 0~0 ms}
0-10113ms (141x): OK | matched: org.chromium.chrome.browser.ChromeTabbedActivity@d416c1d (state=RESUMED)
[2] [ENTER] [OK ] View: (view.getId() is <2130773031/org.chromium.chrome.tests:id/home_button>) {fulfilled after 0~12 ms}
[3] [ENTER] [OK ] View: (view.getId() is <2130774255/org.chromium.chrome.tests:id/tab_switcher_button>) {fulfilled after 0~14 ms}
[4] [ENTER] [OK ] View: (view.getId() is <2130773254/org.chromium.chrome.tests:id/menu_button>) {fulfilled after 0~15 ms}
[5] [ENTER] [OK ] View: (view.getId() is <2130774443/org.chromium.chrome.tests:id/url_bar>) {fulfilled after 0~17 ms}
[6] [ENTER] [OK ] Received 2 didAddTab callbacks {fulfilled after 1790~2242 ms}
17- 1790ms ( 23x): NO | Called 0/2 times
2242- 3523ms ( 23x): OK | Called 1/2 times
3800-10118ms ( 95x): OK | Called 2/2 times
[7] [ENTER] [OK ] Received 2 didSelectTab callbacks {fulfilled after 1790~2242 ms}
17- 1790ms ( 23x): NO | Called 0/2 times
2242- 3523ms ( 23x): OK | Called 1/2 times
3800-10118ms ( 95x): OK | Called 2/2 times
[8] [ENTER] [OK ] Activity tab is the expected one {fulfilled after 1790~2242 ms}
17- 1790ms ( 23x): WAIT | waiting for suppliers of: ExpectedTab
2242- 3523ms ( 23x): OK | matched expected activityTab: org.chromium.chrome.browser.tab.TabImpl@7eaa6a7
3800-10119ms ( 95x): OK | matched expected activityTab: org.chromium.chrome.browser.tab.TabImpl@4a72320
[9] [ENTER] [OK ] Regular tab loaded {fulfilled after 3523~3808 ms}
17- 1790ms ( 23x): WAIT | waiting for suppliers of: ActivityTab
2242- 3523ms ( 23x): NO | incognito false, isLoading true, hasWebContents true, shouldShowLoadingUI true
3808-10119ms ( 95x): OK | incognito false, isLoading false, hasWebContents true, shouldShowLoadingUI false
[10] [ENTER] [OK ] Page interactable or hidden {fulfilled after 3885~4428 ms}
17- 3523ms ( 46x): WAIT | waiting for suppliers of: LoadedTab
3812- 3885ms ( 2x): NO | isUserInteractable=false, isHidden=false
4428-10119ms ( 93x): OK | isUserInteractable=true, isHidden=false
[11] [ENTER] [OK ] Title of activity tab is "Two" {fulfilled after 3523~3814 ms}
17- 3523ms ( 46x): WAIT | waiting for suppliers of: LoadedTab
3814-10119ms ( 95x): OK | ActivityTab title: "Two"
[12] [ENTER] [FAIL] URL of activity tab contains "http://127.0.0.1:45439/chrome/test/data/android/popup_test.html" {unfulfilled after 10119 ms}
17- 3523ms ( 46x): WAIT | waiting for suppliers of: LoadedTab
3815-10119ms ( 95x): NO | ActivityTab url: "http://127.0.0.1:45439/chrome/test/data/android/navigate/two.html"
[13] [ENTER] [OK ] WebContents present {fulfilled after 3523~3815 ms}
18- 3523ms ( 46x): WAIT | waiting for suppliers of: LoadedTab
3815-10120ms ( 95x): OK |
Public Transit increases code reuse between test classes that go through the same test setup and user flow by putting common code in the Transit Layer, including:
ViewMatchers for the same UI elementsThe transition methods shows the “routes” that can be taken to continue from the current state, increasing discoverability of shared code.
It is recommended to batch Public Transit tests to reduce runtime and save CQ/CI resources.
This restarts the Android Activities while keeping the browser process alive. Static fields, singletons and globals are not reset unless ResettersForTesting was used.
@Batch(Batch.PER_CLASS) to the test class.@Rule returned by ChromeTransitTestRules.freshChromeTabbedActivityRule().mCtaTestRule.startOnBlankPage().The BatchedPublicTransitRule is not necessary. Returning to the home station is not necessary. However, this does not run as fast as “reusing the Activity” below, especially in Release.
Example: ExampleFreshCtaTest
This keeps the Activity, but closes all tabs between tests and returns to a blank page.
Using AutoResetCtaTransitTestRule:
@Batch(Batch.PER_CLASS) to the test class.ChromeTransitTestRules.autoResetCtaActivityRule().mCtaTestRule.startOnBlankPage().Tests don't need to return to the home station. Only some reset paths are supported - this is best effort since this reset transition is not part of a regular user flow.
Example: ExampleAutoResetCtaTest
This both keeps the Activiy and doesn't reset any app state between tests (apart from ResettersForTesting) - a test is started immediately after the previous finished.
Using ReusedCtaTransitTestRule:
@Batch(Batch.PER_CLASS) to the test class.ChromeTransitTestRules.blankPageStartReusedActivityRule().mCtaTestRule.start().Each test should return to the home station. If a test does not end in the home station, it will fail (if it already hasn't) with a descriptive message. The following tests will also fail right at the start.
Example: ExampleReusedCtaTest
ViewPrinter is useful to print a View hierarchy to write ViewElements and debug failures. The output with default options looks like this:
@id/control_container | ToolbarControlContainer
├── @id/toolbar_container | ToolbarViewResourceFrameLayout
│ ╰── @id/toolbar | ToolbarPhone
│ ├── @id/home_button | HomeButton
│ ├── @id/location_bar | LocationBarPhone
│ │ ├── @id/location_bar_status | StatusView
│ │ │ ╰── @id/location_bar_status_icon_view | StatusIconView
│ │ │ ╰── @id/location_bar_status_icon_frame | FrameLayout
│ │ │ ╰── @id/loc_bar_status_icon | ChromeImageView
│ │ ╰── "about:blank" | @id/url_bar | UrlBarApi26
│ ╰── @id/toolbar_buttons | LinearLayout
│ ├── @id/tab_switcher_button | ToggleTabStackButton
│ ╰── @id/menu_button_wrapper | MenuButton
│ ╰── @id/menu_button | ChromeImageButton
╰── @id/tab_switcher_toolbar | StartSurfaceToolbarView
├── @id/new_tab_view | LinearLayout
│ ├── AppCompatImageView
│ ╰── "New tab" | MaterialTextView
╰── @id/menu_anchor | FrameLayout
╰── @id/menu_button_wrapper | MenuButton
╰── @id/menu_button | ChromeImageButton
PublicTransitConfig configures the test to run differently for debugging:
setTransitionPauseForDebugging() causes the test to run more slowly, pausing for some time after each transition and displaying a Toast with which Station is active. 1500ms is a good default.setOnExceptionCallback() runs the given callback when an Exception happens during a Transition. Useful to print debug information before the test fails and the app is closed.setFreezeOnException() freezes the test when an Exception happens during a Transition. Useful to see what the screen looks like before the test fails and the instrumented app is closed.The metaphor for the framework is that a Transit Layer provides tests with routes to navigate the app using shared code, as opposed to each test driving its private car (writing its own private code) to set up the test.
A Public Transit test moves around the app by going from Station to Station, and the stations are connected by routes (transition methods). Stations are marked by Elements, which are recognizable features of the destination station (features such as Android Views), which the test takes as evidence that it has arrived and is ready to perform any test-specific operation, checking or further navigation.
At a Station there are Facilities that can be entered, such as menus, dialogs, or more abstract substates, such as data loaded from disk. Transition methods are also used to enter and exit those Facilities.
The metaphor is not very accurate in that Stations and Facilities instances are snapshots of the app state that the test is expected to reach. A user action that changes a selection in a form, for example, would be modeled not by mutating the dialog's Facility, but creating a second instance of the dialog Facility with a property. Stations and Facilities are mostly immutable objects.
Public Transit is structured as follows:
| Layer | Contents | File names | Location | Width (how many files) |
|---|---|---|---|---|
| Test Layer | Instrumentation test classes | *PTTest.java | //chrome/**/javatests | wide |
| Transit Layer | Concrete Stations, Facilities | *Station.java, *Condition.java, etc. | //chrome/test/android/javatests | wide |
| Framework Layer | Public Transit classes | All classes with package org.chromium.base.test.transit.* | //base/test/.../transit | narrow |
The Test Layer contains the JUnit test classes with @Test methods. It should be readable at a high level and delegate the logic that can be shared with other tests to the to Transit Layer.
Code in the Test Layer that uses the Transit Layer should contain no explicit waits; the waits should be modeled as transition methods.
An example of Test Layer code:
@Test public void testOpenTabSwitcher() { PageStation page = mTransitEntryPoints.startOnBlankPage(); AppMenuFacility appMenu = page.openAppMenu(); page = appMenu.openNewIncognitoTab(); TabSwitcherStation tabSwitcher = page.openTabSwitcher(); }
Most of the time these transition methods, such as BasePageStation#openAppMenu(), should be in the Transit Layer for sharing with other tests. Transitions specific to the test can be written in the Test Layer.
The Transit Layer contains the app-specific Stations, Faciltiies, Transitions and Conditions, as well as entry points. This is the bulk of the test code.
The Transit Layer is a representation of what the app looks like in terms of possible states, and how these states can be navigated.
The Framework Layer is the Public Transit library code, which is app-agnostic. It contains the Public Transit concepts of Station, Transition, Condition, etc.
A Station represents one of the app's “screens”, that is, a full (or mostly full) window view. Only one Station can be active at any time.
For each screen in the app, a concrete implementation of Station should be created in the Transit Layer, implementing:
declareExtraElements() declaring the Views and other enter/exit conditions define this Station.Stations or to enter Facilities. These methods are synchronous and return a handle to the entered ConditionalState only after the transition is done and the new ConditionalState becomes ACTIVE.Example of a concrete Station:
/** The tab switcher screen, with the tab grid and the tab management toolbar. */ public class TabSwitcherStation extends Station<ChromeTabbedActivity> { public ViewElement<View> newTabButtonElement; public ViewElement<View> incognitoToggleTabsElement; public TabSwitcherStation() { newTabButtonElement = declareView(withId(R.id.new_tab_button)); incognitoToggleTabsElement = declareView(withId(R.id.incognito_toggle_tabs)); } public NewTabPageStation openNewTabFromButton() { return newTabButtonElement.clickTo().arriveAt(new NewTabPageStation())) } }
A Facility represents things like pop-up menus, dialogs or messages that are scoped to one of the app's “screens”.
Multiple Facilities may be active at one time besides the active Station that contains them.
As with Stations, concrete, app-specific implementations of Facility should be created in the Transit Layer declaring Elements and transition methods.
A [** State **] represents something not tied to a Station, like data written to disk, or a popup that persists through different Stations.
Multiple States may be active at one time.
As with Stations, concrete, app-specific implementations of State should be created in the Transit Layer declaring Elements. It usually won't have any transition methods.
Station, Facility and State extend ConditionalState, which means they declare enter and exit conditions as Elements and have a linear lifecycle:
NEW -> TRANSITIONING_TO -> ACTIVE -> TRANSITIONING_FROM -> FINISHEDOnce FINISHED, a ConditionalState should not be navigated to anymore. If a test comes back to a previous screen, it should be represented by a new Station instance.
Conditions are checks performed to ensure a certain transition is finished.
Common Condition subclasses are provided by the Framework Layer (e.g. ViewConditions and CallbackCondition).
A lightweight way to wait for multiple Conditions without creating any concrete Stations, Facilities or States is to use Condition#runAndWaitFor().
Custom app-specific Conditions should be implemented in the Transit Layer by extending UIThreadCondition or InstrumentationThreadConditions.
A Condition should implement checkWithSuppliers(), which should check the conditions and return fulfilled(), notFulfilled() or awaiting(). An optional but encouraged status message can be provided as argument. These messages are aggregated and printed to logcat with the times they were output in the transition summary. whether() can also be returned as a convenience method.
Custom Conditions may require a dependency to be checked which might not exist before the transition's trigger is run. They should take the dependency as a constructor argument of type Condition or Element that implements Supplier<DependencyT> and call dependOnSupplier(). The dependency should supply DependencyT when fulfilled.
An example of a custom condition:
class PageLoadedCondition extends UiThreadCondition { private Supplier<Tab> mTabSupplier; PageLoadedCondition(Supplier<Tab> tabCondition) { mTabSupplier = dependOnSupplier(tabCondition, "Tab"); } @Override public String buildDescription() { return "Tab loaded"; } @Override public ConditionStatus checkWithSuppliers() { Tab tab = mTabSupplier.get(); boolean isLoading = tab.isLoading(); boolean showLoadingUi = tab.getWebContents().shouldShowLoadingUI(); return whether( !isLoading && !showLoadingUI, "isLoading %b, showLoadingUi %b", isLoading, showLoadingUi); } }
From the point of view of the Test Layer, transitions methods are blocking. When a Station or Facility is returned by one of those methods, it is always ACTIVE and can be immediately acted upon without further waiting.
Transitions are triggered by methods that end in To(). Some common ones are clickTo(), runTo(), pressBackTo(). This doesn't execute the transition, but creates a TripBuilder. When arriveAt(), enterFacility(), waitForConditions() or other methods are called in the TripBuilder, the transition is executed.
Transitions between Stations are done by calling arriveAt().
Transitions into and out of Facilities are done by calling enterFacility(), enterFacilities() exitFacility() or exitFacilities(). If the app moves to another Station, any active Facilities have their exit conditions added to the transition conditions.
Transitions into and out of States are done by calling enterState() and exitState().
Conditions not tied to Conditional States can be checked with waitForConditions().
Multiple expectations can be chained by the methods ending with And(), e.g. clickTo().waitForConditionsAnd(c1, c2).exitFacilityAnd(e).enterFacility(f).
The Conditions of a transition are the aggregation of:
ConditionalState being entered.ConditionalState being exited unless the same Element is in a state being entered too.TripBuilder.The way a Transition works is:
ACTIVE to TRANSITIONING_FROM and the states being entered go from Phase NEW to TRANSITIONING_TO.Conditions to complete the Transition are determined by comparing Elements of states being exited and the ones being entered.Trigger lambda is run.ConditionWaiter polls, checking the Conditions each cycle.TRANSITIONING_FROM to FINISHED and the states being entered go from Phase TRANSITIONING_TO to ACTIVE.ACTIVE, is returned to the transit layer and then to the test layer.TransitionOptions let individual Transitions be customized, adjusting timeouts, adding retries, or disabling the pre-check.
The Chrome-specific Stations, Facilities, States and Conditions that comprise the Transit Layer should be owned by the same team responsible for the related production code.
The exception is the core of the Transit Layer, for example PageStation, which is not owned by specific teams, and will be owned by Clank Build/Code Health.
It is possible to write tests that start as a Public Transit test and use the Transit layer to navigate to a certain point, then “hop off” framework and continue navigating the app as a regular instrumentation test.
While it is preferable to model all transitions in the Transit Layer, a test that uses Public Transit partially also realizes its benefits partially and there should be no framework impediment to doing so.
Metaphorically, if there is no public transit to an address, you ride it as close as possible and continue on foot.