Skip to content

Commit dde34a2

Browse files
authored
test: add Android and iOS device tests run through dotnet test --device (#4506)
## Summary **ReactiveUI's Android and iOS platform code now has tests that run on a real emulator and simulator, in CI and locally.** - **`ReactiveUI.Device.Tests` replaces `ReactiveUI.NonParallel.Mobile.Tests`.** The old project used xUnit and NUnit runners, referenced a project that no longer exists, and was never built. - **The Android head runs through the .NET SDK's `dotnet test --device`.** An instrumentation runs TUnit in the app and streams each result back, and `dotnet test` writes the TRX report on the host. - **The iOS head runs every test when UIKit connects its scene.** It writes a TRX report and an exit-code file, then exits with the test run's code. - **Android tests cover** `HandlerSequencer`, the `WithAndroidX` registrations, `PlatformOperations`, activity and fragment activation, `StartActivityForResultAsync`, `ControlFetcherMixins` on views, activities, layout hosts and AndroidX fragments, `LayoutViewHost`, `ReactiveViewHost`, `AutoSuspendHelper`, `BundleSuspensionDriver`, shared-preference changes, service binding, and the RecyclerView and pager adapters. - **iOS tests cover** `NSRunloopSequencer`, the UIKit registrations, `ViewModelViewHost`, `RoutedViewHost`, `ReactiveViewController` activation, `AppSupportJsonSuspensionDriver` and `IndexNormalizer`. - **The project is in `reactiveui.slnx`, so every CI leg builds it.** `ci-build.yml` and `sonarcloud.yml` leave it out of the host test run with `!tests/ReactiveUI.Device.Tests/**`. - **`device-tests.yml` runs both heads through the new actions-common device-tests workflow.** It uses `workflow-common-device-tests.yml@main`. ## Why **Nothing exercised the code under `Platforms/android`, `apple-common` and `uikit-common` or `ReactiveUI.AndroidX`, because it only runs on a device.** ## Breaking changes None. Only tests and CI change. ## How this was verified **The Android head ran on a local emulator, in Debug and Release, through the same device-tests script CI runs.** - The iOS head builds on Windows in Debug and Release. It could not run here: the simulator needs macOS, so its first run is this PR's `device-tests (ios)` job. - Run it locally with `dotnet run --file ../actions-common/scripts/device-tests.cs -- android --project src/tests/ReactiveUI.Device.Tests/ReactiveUI.Device.Tests.csproj`; the project README has the details. ## Notes for the reviewer **Start with `Runner/DeviceTestSession.cs` and the two heads, `Platforms/Android/TestInstrumentation.cs` and `Platforms/iOS/IosTestHost.cs`; the test classes are straightforward.** - **`Runner/ConsoleCancelKeyShim.cs` works around a TUnit 1.69 bug.** TUnit subscribes to `Console.CancelKeyPress` at the start of a run and skips that only in the browser, so every run on Android or iOS fails before the first test. The shim fills the console's signal registration with an inert one. It should go once TUnit guards Android and iOS; that needs an issue on TUnit. - **The iOS head sets `EnableTrimAnalyzer` to false.** The app is not trimmed (`MtouchLink=None`), so the analysis only reports the reflection paths the tests cover; the SDK's MSTest runner does the same. Please confirm that is acceptable under the no-suppression rule. - **`LayoutViewHost` with `ResolveStrategy.ExplicitOptOut` always throws.** The host's own public `View` property is a writable `View`, so the opt-out strategy tries to wire it by name and fails. The tests cover opt-out on a plain view instead; the host bug is left for a separate fix. - **Two more behaviours look wrong and are left as they are.** `ReactiveActivity`'s `IViewFor.ViewModel` setter assigns the field without raising a change, unlike the other hosts. `ContextExtensions.ServiceBound` disposes the context it was given when the subscription ends, so the tests pass it a `ContextWrapper`. - **Out of scope:** the `*Unsafe` view hosts, which are not on `main` yet (#4505), and `UsbManagerExtensions`, which needs USB hardware. The device run collects no coverage: the coverage collector instruments a desktop test process. ## Checklist - [x] I have read the [Contribute guide](https://www.reactiveui.net/contribute/index.html) - [x] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) - [x] Tests cover this change, or the summary says why they do not - [ ] New or changed public API has XML documentation
1 parent bbe16fa commit dde34a2

83 files changed

Lines changed: 3643 additions & 776 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci-build.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ jobs:
2323
installWorkloads: true
2424
installWindowsAppRuntime: true
2525
# Test only the test projects: the solution also builds the documentation example apps, which are not tests.
26-
testProjects: tests/**/*.csproj
26+
# The device test app needs an emulator or simulator; device-tests.yml runs it.
27+
testProjects: |
28+
tests/**/*.csproj
29+
!tests/ReactiveUI.Device.Tests/**
2730
secrets:
2831
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

‎.github/workflows/device-tests.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Device Tests
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
device-tests:
15+
uses: reactiveui/actions-common/.github/workflows/workflow-common-device-tests.yml@main
16+
with:
17+
projects: src/tests/ReactiveUI.Device.Tests/ReactiveUI.Device.Tests.csproj
18+
configuration: Release
19+
solutionFile: reactiveui.slnx

‎.github/workflows/sonarcloud.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ jobs:
1717
installWorkloads: true
1818
installWindowsAppRuntime: true
1919
# Test only the test projects: the solution also builds the documentation example apps, which are not tests.
20-
testProjects: tests/**/*.csproj
20+
# The device test app needs an emulator or simulator; device-tests.yml runs it.
21+
testProjects: |
22+
tests/**/*.csproj
23+
!tests/ReactiveUI.Device.Tests/**
2124
sonarProjectKey: reactiveui_ReactiveUI
2225
sonarOrganization: reactiveui
2326
sonarExclusions: '**/tests/**,**/integrationtests/**,**/benchmarks/**,**/examples/**,**/TestResults/**'

‎src/reactiveui.slnx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
<Folder Name="/Solution Items/Workflows/">
5959
<File Path="../.github/workflows/ci-build.yml" />
6060
<File Path="../.github/workflows/codeql.yml" />
61+
<File Path="../.github/workflows/device-tests.yml" />
6162
<File Path="../.github/workflows/lock.yml" />
6263
<File Path="../.github/workflows/release.yml" />
6364
<File Path="../.github/workflows/sonarcloud.yml" />
@@ -68,6 +69,7 @@
6869
<Project Path="tests/ReactiveUI.Blazor.Tests.Reactive/ReactiveUI.Blazor.Tests.Reactive.csproj" />
6970
<Project Path="tests/ReactiveUI.Builder.Maui.Tests/ReactiveUI.Builder.Maui.Tests.csproj" />
7071
<Project Path="tests/ReactiveUI.Builder.Tests/ReactiveUI.Builder.Tests.csproj" />
72+
<Project Path="tests/ReactiveUI.Device.Tests/ReactiveUI.Device.Tests.csproj" />
7173
<Project Path="tests/ReactiveUI.Maui.Tests/ReactiveUI.Maui.Tests.csproj" />
7274
<Project Path="tests/ReactiveUI.Splat.Tests/ReactiveUI.Splat.Tests.csproj" />
7375
<Project Path="tests/ReactiveUI.Test.Utilities/ReactiveUI.Test.Utilities.csproj" />
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using Android.Widget;
7+
8+
namespace ReactiveUI.Device.Tests;
9+
10+
/// A that counts its activations and wires its layout by name.
11+
[Activity(Exported = false)]
12+
public class ActivatingActivity : ReactiveActivity<TestViewModel>
13+
{
14+
/// The number of times the activity has been activated.
15+
private int _activations;
16+
17+
/// The number of times the activity has been deactivated.
18+
private int _deactivations;
19+
20+
/// Initializes a new instance of the class.
21+
public ActivatingActivity() =>
22+
this.WhenActivated(disposables =>
23+
{
24+
_ = Interlocked.Increment(ref _activations);
25+
disposables(new ActionDisposable(() => Interlocked.Increment(ref _deactivations)));
26+
});
27+
28+
/// Gets the most recently created instance, so a test can reach the instance Android creates when it recreates one.
29+
public static ActivatingActivity? LastCreated { get; private set; }
30+
31+
/// Gets the number of times the activity has been activated.
32+
public int Activations => Volatile.Read(ref _activations);
33+
34+
/// Gets a value indicating whether Android passed saved state to .
35+
public bool CreatedFromSavedState { get; private set; }
36+
37+
/// Gets the number of times the activity has been deactivated.
38+
public int Deactivations => Volatile.Read(ref _deactivations);
39+
40+
/// Gets or sets the title control, wired by name.
41+
public TextView? TitleText { get; set; }
42+
43+
/// Gets or sets the save button, wired by name.
44+
public Button? SaveButton { get; set; }
45+
46+
/// Gets the error wiring the controls threw, if it threw.
47+
public Exception? WireUpError { get; private set; }
48+
49+
///
50+
protected override void OnCreate(Bundle? savedInstanceState)
51+
{
52+
base.OnCreate(savedInstanceState);
53+
CreatedFromSavedState = savedInstanceState is not null;
54+
LastCreated = this;
55+
SetContentView(Resource.Layout.wireup_layout);
56+
57+
try
58+
{
59+
this.WireUpControls();
60+
}
61+
catch (MissingFieldException ex)
62+
{
63+
WireUpError = ex;
64+
}
65+
}
66+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using Android.Views;
7+
using Android.Widget;
8+
using ReactiveUI.AndroidX;
9+
10+
namespace ReactiveUI.Device.Tests;
11+
12+
/// A that hosts a .
13+
[Activity(Exported = false)]
14+
public class CompatHostActivity : ReactiveAppCompatActivity<TestViewModel>
15+
{
16+
/// The number of times the activity has been activated.
17+
private int _activations;
18+
19+
/// Initializes a new instance of the class.
20+
public CompatHostActivity() =>
21+
this.WhenActivated((Action<IDisposable> _) => Interlocked.Increment(ref _activations));
22+
23+
/// Gets the number of times the activity has been activated.
24+
public int Activations => Volatile.Read(ref _activations);
25+
26+
/// Gets the hosted fragment.
27+
public WiredFragment Fragment { get; } = new();
28+
29+
///
30+
protected override void OnCreate(Bundle? savedInstanceState)
31+
{
32+
base.OnCreate(savedInstanceState);
33+
34+
var container = new FrameLayout(this) { Id = View.GenerateViewId() };
35+
SetContentView(container);
36+
SupportFragmentManager!.BeginTransaction()!.Add(container.Id, Fragment)!.CommitNow();
37+
}
38+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using Android.OS;
7+
8+
namespace ReactiveUI.Device.Tests;
9+
10+
/// The binder returns; a local client receives this same instance.
11+
public class EchoBinder : Binder
12+
{
13+
/// Returns the text it was given, so a test can call through the bound service.
14+
/// The text to echo.
15+
/// The same text.
16+
public string Echo(string text) => text;
17+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using Android.Content;
7+
using Android.OS;
8+
9+
namespace ReactiveUI.Device.Tests;
10+
11+
/// A local bound service the service-binding tests connect to.
12+
[Service(Exported = false)]
13+
public class EchoService : Service
14+
{
15+
/// The binder handed to every client.
16+
private readonly EchoBinder _binder = new();
17+
18+
///
19+
public override IBinder? OnBind(Intent? intent) => _binder;
20+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using Android.Widget;
7+
8+
namespace ReactiveUI.Device.Tests;
9+
10+
/// A plain activity whose content view the tests add views to, so the views attach to a real window.
11+
[Activity(Exported = false)]
12+
public class HostActivity : Activity
13+
{
14+
/// Gets the container the tests add views to.
15+
public FrameLayout Container { get; private set; } = null!;
16+
17+
///
18+
protected override void OnCreate(Bundle? savedInstanceState)
19+
{
20+
base.OnCreate(savedInstanceState);
21+
Container = new(this);
22+
SetContentView(Container);
23+
}
24+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using Android.Content;
7+
using AndroidResult = Android.App.Result;
8+
9+
namespace ReactiveUI.Device.Tests;
10+
11+
/// An activity that returns a result to its caller as soon as it is created.
12+
[Activity(Exported = false)]
13+
public class ResultSourceActivity : Activity
14+
{
15+
/// The intent extra key carrying the answer.
16+
internal const string AnswerKey = "answer";
17+
18+
/// The answer the activity returns.
19+
internal const int Answer = 42;
20+
21+
///
22+
protected override void OnCreate(Bundle? savedInstanceState)
23+
{
24+
base.OnCreate(savedInstanceState);
25+
26+
var data = new Intent();
27+
_ = data.PutExtra(AnswerKey, Answer);
28+
SetResult(AndroidResult.Ok, data);
29+
Finish();
30+
}
31+
}

0 commit comments

Comments
 (0)