Add a library of test options for use in scenario tests.

Bug: 118122395
Change-Id: I8ba3d5822761c028a5f3d81935f161bbe8967937
Merged-In: I524d39b983cab8683bd90752d185658d54b89f75
(cherry picked from commit ddb1623814be10c69bacac0ced9f3c39f558c0c0)
diff --git a/libraries/options/Android.bp b/libraries/options/Android.bp
new file mode 100644
index 0000000..2d572d4
--- /dev/null
+++ b/libraries/options/Android.bp
@@ -0,0 +1,23 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+java_library_static {
+    name: "platform-test-options",
+    sdk_version: "24",
+    srcs: [ "src/**/*.java" ],
+    libs: [
+        "androidx.test.runner",
+        "junit",
+    ],
+}
diff --git a/libraries/options/src/android/platform/test/options/BooleanOption.java b/libraries/options/src/android/platform/test/options/BooleanOption.java
new file mode 100644
index 0000000..a8cb948
--- /dev/null
+++ b/libraries/options/src/android/platform/test/options/BooleanOption.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+/** A boolean option for a scenario. */
+public class BooleanOption extends TestOption<Boolean> {
+    public BooleanOption(String optionName) {
+        super(optionName);
+    }
+
+    @Override
+    protected Boolean parseValueFromString(String value) {
+        return Boolean.valueOf(value);
+    }
+}
diff --git a/libraries/options/src/android/platform/test/options/DoubleOption.java b/libraries/options/src/android/platform/test/options/DoubleOption.java
new file mode 100644
index 0000000..ccd389c
--- /dev/null
+++ b/libraries/options/src/android/platform/test/options/DoubleOption.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+/** A double option for a scenario. */
+public class DoubleOption extends TestOption<Double> {
+    public DoubleOption(String optionName) {
+        super(optionName);
+    }
+
+    @Override
+    protected Double parseValueFromString(String value) {
+        return Double.valueOf(value);
+    }
+}
diff --git a/libraries/options/src/android/platform/test/options/IntegerOption.java b/libraries/options/src/android/platform/test/options/IntegerOption.java
new file mode 100644
index 0000000..63eeaf2
--- /dev/null
+++ b/libraries/options/src/android/platform/test/options/IntegerOption.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+/** An integer option for a scenario. */
+public class IntegerOption extends TestOption<Integer> {
+    public IntegerOption(String optionName) {
+        super(optionName);
+    }
+
+    @Override
+    protected Integer parseValueFromString(String value) {
+        return Integer.valueOf(value);
+    }
+}
diff --git a/libraries/options/src/android/platform/test/options/LongOption.java b/libraries/options/src/android/platform/test/options/LongOption.java
new file mode 100644
index 0000000..9fc7048
--- /dev/null
+++ b/libraries/options/src/android/platform/test/options/LongOption.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+/** A long option for a scenario. */
+public class LongOption extends TestOption<Long> {
+    public LongOption(String optionName) {
+        super(optionName);
+    }
+
+    @Override
+    protected Long parseValueFromString(String value) {
+        return Long.valueOf(value);
+    }
+}
diff --git a/libraries/options/src/android/platform/test/options/StringOption.java b/libraries/options/src/android/platform/test/options/StringOption.java
new file mode 100644
index 0000000..5ac6623
--- /dev/null
+++ b/libraries/options/src/android/platform/test/options/StringOption.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+/** A string option for a scenario. */
+public class StringOption extends TestOption<String> {
+    public StringOption(String optionName) {
+        super(optionName);
+    }
+
+    @Override
+    protected String parseValueFromString(String value) {
+        return value;
+    }
+}
diff --git a/libraries/options/src/android/platform/test/options/TestOption.java b/libraries/options/src/android/platform/test/options/TestOption.java
new file mode 100644
index 0000000..b73264d
--- /dev/null
+++ b/libraries/options/src/android/platform/test/options/TestOption.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+import android.os.Bundle;
+import androidx.test.InstrumentationRegistry;
+import androidx.annotation.VisibleForTesting;
+
+import org.junit.runner.Description;
+import org.junit.rules.TestRule;
+import org.junit.runners.model.Statement;
+
+/**
+ * A base class whose implementations encompass options for a scenario test.
+ *
+ * <p>Each option is defined by calling its constructor with its argument string and then optionally
+ * calling a number of setters to set the default value and whether it is required. They need to be
+ * defined as JUnit test rules using the {@code Rule} annotation as this ensures that they are
+ * initialized during test setup.
+ *
+ * <p>Using {@link StringOption} as an example, an option is defined as the following:
+ *
+ * <pre>
+ * @Rule public StringOption sampleOption =
+ *          new StringOption("sample-option").setRequired(true).setDefault("sample-value");
+ * </pre>
+ *
+ * <p>To supply value for the above option, use {@code -e sample-option some_value} in the
+ * instrumentation command.
+ */
+public abstract class TestOption<T> implements TestRule {
+    private String mOptionName;
+    private T mDefaultValue;
+    private boolean mIsRequired;
+    private T mValue;
+
+    @Override
+    public Statement apply(Statement base, Description description) {
+        return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+                // An option with a default value is effectively not required.
+                if (mDefaultValue != null) {
+                    mIsRequired = false;
+                }
+                // Populate the option value before the test starts, and throw if the option is
+                // missing when required or illegal.
+                Bundle arguments = getArguments();
+                if (!arguments.containsKey(mOptionName)) {
+                    if (mIsRequired) {
+                        throw new IllegalArgumentException(
+                                String.format(
+                                        "Missing argument for required option: %s", mOptionName));
+                    }
+                    mValue = mDefaultValue;
+                } else {
+                    // All `am instrument -e` arguments are provided as Strings.
+                    try {
+                        mValue = parseValueFromString(arguments.getString(mOptionName));
+                    } catch (Exception e) {
+                        throw new RuntimeException(
+                                String.format(
+                                        "Error parsing option %s for test %s.",
+                                        TestOption.this, description),
+                                e);
+                    }
+                }
+
+                // Run the underlying statement which includes the test, if the above pass.
+                base.evaluate();
+            }
+        };
+    }
+
+    /** Creates a required option with the provided name. */
+    public TestOption(String optionName) {
+        mOptionName = optionName;
+        mIsRequired = true;
+    }
+
+    /**
+     * Sets whether the option is required when no default is provided. Options are considered
+     * required by default.
+     */
+    public <S extends TestOption<T>> S setRequired(boolean required) {
+        mIsRequired = required;
+        return (S) this;
+    }
+
+    /** Sets a default value for an option. A null value is not a proper default value. */
+    public <S extends TestOption<T>> S setDefault(T defaultValue) {
+        if (defaultValue == null) {
+            throw new IllegalArgumentException(
+                    String.format("Default value for option %s cannot be null.", this));
+        }
+        mDefaultValue = defaultValue;
+        return (S) this;
+    }
+
+    /**
+     * Returns the value of the option which had been initialized during test setup. Can return null
+     * if the option is not set to required.
+     */
+    public T get() {
+        if ((mValue == null) && mIsRequired) {
+            // This should never happen.
+            throw new IllegalStateException(
+                    String.format(
+                            "Value for required option %s had not been initialized during setup.",
+                            mOptionName));
+        }
+        // A non-required option can return null.
+        return mValue;
+    }
+
+    /**
+     * Returns the set value based on registered arguments or the default value if not.
+     *
+     * <p>Note: this should override any defaults already provided by the {@link Bundle}.
+     */
+    protected abstract T parseValueFromString(String value);
+
+    @VisibleForTesting
+    Bundle getArguments() {
+        return InstrumentationRegistry.getArguments();
+    }
+
+    public String toString() {
+        return String.format("%s \"%s\"", this.getClass().getSimpleName(), mOptionName);
+    }
+}
diff --git a/libraries/options/tests/Android.bp b/libraries/options/tests/Android.bp
new file mode 100644
index 0000000..3099de5
--- /dev/null
+++ b/libraries/options/tests/Android.bp
@@ -0,0 +1,24 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+android_test {
+    name: "PlatformTestOptionsTests",
+    sdk_version: "24",
+    srcs: [ "src/**/*.java" ],
+    static_libs: [
+        "androidx.test.runner",
+        "platform-test-options",
+        "junit",
+    ],
+}
diff --git a/libraries/options/tests/AndroidManifest.xml b/libraries/options/tests/AndroidManifest.xml
new file mode 100644
index 0000000..5d8830f
--- /dev/null
+++ b/libraries/options/tests/AndroidManifest.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2019 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.platform.test.options.tests" >
+    <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="24" />
+    <application>
+        <uses-library android:name="android.test.runner"/>
+    </application>
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.platform.test.options.tests"
+        android:label="Tests for Platform Test Options" />
+</manifest>
+
diff --git a/libraries/options/tests/AndroidTest.xml b/libraries/options/tests/AndroidTest.xml
new file mode 100644
index 0000000..59e8a89
--- /dev/null
+++ b/libraries/options/tests/AndroidTest.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (c) 2018 Google Inc.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<configuration description="Config for platform test options">
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="cleanup-apks" value="true" />
+        <option name="test-file-name" value="PlatformTestOptionsTests.apk" />
+    </target_preparer>
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="android.platform.test.options.tests" />
+    </test>
+</configuration>
diff --git a/libraries/options/tests/src/android/platform/test/options/BooleanOptionTest.java b/libraries/options/tests/src/android/platform/test/options/BooleanOptionTest.java
new file mode 100644
index 0000000..47a8af6
--- /dev/null
+++ b/libraries/options/tests/src/android/platform/test/options/BooleanOptionTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+import android.os.Bundle;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/** Unit tests for {@link BooleanOption}. */
+public class BooleanOptionTest {
+    private static final String OPTION_NAME = "option";
+
+    private static class TestableBooleanOption extends BooleanOption {
+        private Bundle mArguments = new Bundle();
+        private String mName;
+
+        public TestableBooleanOption(String name) {
+            super(name);
+            mName = name;
+        }
+
+        @Override
+        Bundle getArguments() {
+            return mArguments;
+        }
+
+        public void stubValue(String value) {
+            mArguments.putString(mName, value);
+        }
+    }
+
+    /** Test that the option is parsed correctly for a "true" value. */
+    @Test
+    public void testParsing_true() throws Throwable {
+        TestableBooleanOption option = new TestableBooleanOption(OPTION_NAME);
+        option.stubValue(String.valueOf(true));
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(true, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that the option is parsed correctly for a "false" value equivalent. */
+    @Test
+    public void testParsing_false() throws Throwable {
+        TestableBooleanOption option = new TestableBooleanOption(OPTION_NAME);
+        option.stubValue("not true");
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(false, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+}
diff --git a/libraries/options/tests/src/android/platform/test/options/DoubleOptionTest.java b/libraries/options/tests/src/android/platform/test/options/DoubleOptionTest.java
new file mode 100644
index 0000000..0b284af
--- /dev/null
+++ b/libraries/options/tests/src/android/platform/test/options/DoubleOptionTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+import android.os.Bundle;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/** Unit tests for {@link DoubleOption}. */
+public class DoubleOptionTest {
+    @Rule public ExpectedException mThrown = ExpectedException.none();
+
+    private static final String OPTION_NAME = "option";
+
+    private static class TestableDoubleOption extends DoubleOption {
+        private Bundle mArguments = new Bundle();
+        private String mName;
+
+        public TestableDoubleOption(String name) {
+            super(name);
+            mName = name;
+        }
+
+        @Override
+        Bundle getArguments() {
+            return mArguments;
+        }
+
+        public void stubValue(String value) {
+            mArguments.putString(mName, value);
+        }
+    }
+
+    /** Test that the option is parsed correctly for a valid double value. */
+    @Test
+    public void testParsing_valid() throws Throwable {
+        TestableDoubleOption option = new TestableDoubleOption(OPTION_NAME);
+        // Using boxed value here to avoid ambiguity when calling assertEquals.
+        Double value = 1.1;
+        option.stubValue(String.valueOf(value));
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(value, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that the option throws when using an invalid number format. */
+    @Test
+    public void testParsing_invalid() throws Throwable {
+        mThrown.expectMessage("Error parsing");
+
+        TestableDoubleOption option = new TestableDoubleOption(OPTION_NAME);
+        option.stubValue("not a number");
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        option.get();
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+}
diff --git a/libraries/options/tests/src/android/platform/test/options/IntegerOptionTest.java b/libraries/options/tests/src/android/platform/test/options/IntegerOptionTest.java
new file mode 100644
index 0000000..5258be5
--- /dev/null
+++ b/libraries/options/tests/src/android/platform/test/options/IntegerOptionTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+import android.os.Bundle;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/** Unit tests for {@link IntegerOption}. */
+public class IntegerOptionTest {
+    @Rule public ExpectedException mThrown = ExpectedException.none();
+
+    private static final String OPTION_NAME = "option";
+
+    private static class TestableIntegerOption extends IntegerOption {
+        private Bundle mArguments = new Bundle();
+        private String mName;
+
+        public TestableIntegerOption(String name) {
+            super(name);
+            mName = name;
+        }
+
+        @Override
+        Bundle getArguments() {
+            return mArguments;
+        }
+
+        public void stubValue(String value) {
+            mArguments.putString(mName, value);
+        }
+    }
+
+    /** Test that the option is parsed correctly for a valid integer value. */
+    @Test
+    public void testParsing_valid() throws Throwable {
+        TestableIntegerOption option = new TestableIntegerOption(OPTION_NAME);
+        // Using boxed value here to avoid ambiguity when calling assertEquals.
+        Integer value = 7;
+        option.stubValue(String.valueOf(value));
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(value, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that the option throws when using an invalid number format. */
+    @Test
+    public void testParsing_invalid() throws Throwable {
+        mThrown.expectMessage("Error parsing");
+
+        TestableIntegerOption option = new TestableIntegerOption(OPTION_NAME);
+        option.stubValue("not an integer");
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        option.get();
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+}
diff --git a/libraries/options/tests/src/android/platform/test/options/LongOptionTest.java b/libraries/options/tests/src/android/platform/test/options/LongOptionTest.java
new file mode 100644
index 0000000..61df321
--- /dev/null
+++ b/libraries/options/tests/src/android/platform/test/options/LongOptionTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+import android.os.Bundle;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/** Unit tests for {@link LongOption}. */
+public class LongOptionTest {
+    @Rule public ExpectedException mThrown = ExpectedException.none();
+
+    private static final String OPTION_NAME = "option";
+
+    private static class TestableLongOption extends LongOption {
+        private Bundle mArguments = new Bundle();
+        private String mName;
+
+        public TestableLongOption(String name) {
+            super(name);
+            mName = name;
+        }
+
+        @Override
+        Bundle getArguments() {
+            return mArguments;
+        }
+
+        public void stubValue(String value) {
+            mArguments.putString(mName, value);
+        }
+    }
+
+    /** Test that the option is parsed correctly for a valid long value. */
+    @Test
+    public void testParsing_valid() throws Throwable {
+        TestableLongOption option = new TestableLongOption(OPTION_NAME);
+        // Using boxed value here to avoid ambiguity when calling assertEquals.
+        Long value = 7L;
+        option.stubValue(String.valueOf(value));
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(value, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that the option throws when using an invalid number format. */
+    @Test
+    public void testParsing_invalid() throws Throwable {
+        mThrown.expectMessage("Error parsing");
+
+        TestableLongOption option = new TestableLongOption(OPTION_NAME);
+        option.stubValue("not an long");
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        option.get();
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+}
diff --git a/libraries/options/tests/src/android/platform/test/options/StringOptionTest.java b/libraries/options/tests/src/android/platform/test/options/StringOptionTest.java
new file mode 100644
index 0000000..3bdb309
--- /dev/null
+++ b/libraries/options/tests/src/android/platform/test/options/StringOptionTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+import android.os.Bundle;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/** Unit tests for {@link StringOption}. */
+public class StringOptionTest {
+    private static final String OPTION_NAME = "option";
+
+    private static class TestableStringOption extends StringOption {
+        private Bundle mArguments = new Bundle();
+        private String mName;
+
+        public TestableStringOption(String name) {
+            super(name);
+            mName = name;
+        }
+
+        @Override
+        Bundle getArguments() {
+            return mArguments;
+        }
+
+        public void stubValue(String value) {
+            mArguments.putString(mName, value);
+        }
+    }
+
+    /** Test that the option is retrieved correctly. */
+    @Test
+    public void testRetrieval() throws Throwable {
+        TestableStringOption option = new TestableStringOption(OPTION_NAME);
+        String value = "val";
+        option.stubValue(value);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(value, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+}
diff --git a/libraries/options/tests/src/android/platform/test/options/TestOptionTest.java b/libraries/options/tests/src/android/platform/test/options/TestOptionTest.java
new file mode 100644
index 0000000..5d1ef5e
--- /dev/null
+++ b/libraries/options/tests/src/android/platform/test/options/TestOptionTest.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.platform.test.option;
+
+import android.os.Bundle;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Unit tests for {@link TestOption}. */
+public class TestOptionTest {
+    @Rule public ExpectedException mThrown = ExpectedException.none();
+
+    private static final String OPTION_NAME = "option";
+    private static final String VALUE_VALID = "valid";
+    private static final String VALUE_INVALID = "invalid";
+
+    // A testable TestOption class that enables stubbing of the option value.
+    private static class TestableOption extends TestOption<String> {
+        // If stubValue() is not called, the Bundle is empty and thus does not contain the option
+        // value.
+        private Bundle mArguments = new Bundle();
+        private String mName;
+
+        public TestableOption(String name) {
+            super(name);
+            mName = name;
+        }
+
+        @Override
+        public String parseValueFromString(String value) {
+            // Mimick invalid argument behavior.
+            if (value == VALUE_INVALID) {
+                throw new RuntimeException("Invalid argument");
+            }
+
+            return value;
+        }
+
+        @Override
+        Bundle getArguments() {
+            return mArguments;
+        }
+
+        public void stubValue(String value) {
+            mArguments.putString(mName, value);
+        }
+    }
+
+    /** Test that a valid argument is properly accessed. */
+    @Test
+    public void testSuppliedValue_valid() throws Throwable {
+        TestableOption option = new TestableOption(OPTION_NAME);
+        option.stubValue(VALUE_VALID);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(VALUE_VALID, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that an invalid argument is rejected. */
+    @Test
+    public void testSuppliedValue_invalid() throws Throwable {
+        mThrown.expectMessage("Error parsing");
+
+        TestableOption option = new TestableOption(OPTION_NAME);
+        option.stubValue(VALUE_INVALID);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(VALUE_VALID, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that the option throws for a missing argument explicitly set to be required. */
+    @Test
+    public void testRequiredValueAbsent_explicit() throws Throwable {
+        mThrown.expectMessage("Missing argument");
+
+        TestableOption option = new TestableOption(OPTION_NAME).setRequired(true);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        option.get();
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that an option is required by default and throws if absent in instrumentation args. */
+    @Test
+    public void testRequiredValueAbsent_implicit() throws Throwable {
+        mThrown.expectMessage("Missing argument");
+
+        TestableOption option = new TestableOption(OPTION_NAME);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        option.get();
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that an option with a default value will not throw, even if it is required. */
+    @Test
+    public void testRequiredValueAbsent_withDefault() throws Throwable {
+        TestableOption option =
+                new TestableOption(OPTION_NAME).setRequired(true).setDefault(VALUE_VALID);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertEquals(VALUE_VALID, option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that a non-required option can return null if not provided when accessed in test. */
+    @Test
+    public void testNonRequiredValueAbsent() throws Throwable {
+        TestableOption option = new TestableOption(OPTION_NAME).setRequired(false);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        Assert.assertNull(option.get());
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        withOption.evaluate();
+    }
+
+    /** Test that an invalid option value will cause the test to be skipped. */
+    @Test
+    public void testOptionErrorSkipsTest_invalidValue() throws Throwable {
+        TestableOption option = new TestableOption(OPTION_NAME);
+        option.stubValue(VALUE_INVALID);
+        // AtomicBoolean is used to allow for modification in the test statement.
+        final AtomicBoolean testReached = new AtomicBoolean(false);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        testReached.set(true);
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        try {
+            withOption.evaluate();
+        } catch (Throwable e) {
+            // Expected; do nothing.
+        } finally {
+            Assert.assertFalse(testReached.get());
+        }
+    }
+
+    /** Test that a missing required option will cause the test to be skipped. */
+    @Test
+    public void testOptionErrorSkipsTest_requiredMissing() throws Throwable {
+        TestableOption option = new TestableOption(OPTION_NAME).setRequired(true);
+        // AtomicBoolean is used to allow for modification in the test statement.
+        final AtomicBoolean testReached = new AtomicBoolean(false);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        testReached.set(true);
+                    }
+                };
+        Statement withOption = option.apply(testStatement, Description.EMPTY);
+        try {
+            withOption.evaluate();
+        } catch (Throwable e) {
+            // Expected; do nothing.
+        } finally {
+            Assert.assertFalse(testReached.get());
+        }
+    }
+
+    /**
+     * Test that if an option had not been initialized before the test, the test will fail when
+     * accessing the option.
+     *
+     * <p>This should never happen in practice, but tested just in case that custom runners do
+     * something unexpected.
+     */
+    @Test
+    public void testUninitializedOptionThrows() throws Throwable {
+        mThrown.expectMessage("had not been initialized");
+
+        TestableOption option = new TestableOption(OPTION_NAME).setRequired(true);
+        option.stubValue(VALUE_VALID);
+        Statement testStatement =
+                new Statement() {
+                    @Override
+                    public void evaluate() throws Throwable {
+                        option.get();
+                    }
+                };
+        // Run the "test statement" directly without running the option rule, which should throw.
+        testStatement.evaluate();
+    }
+}
diff --git a/tests/scenario/Android.bp b/tests/scenario/Android.bp
index 28abfbf..3afce78 100644
--- a/tests/scenario/Android.bp
+++ b/tests/scenario/Android.bp
@@ -19,6 +19,7 @@
     libs: [
         "androidx.test.runner",
         "junit",
+        "platform-test-options",
         "ub-uiautomator",
     ],
 }
diff --git a/tests/scenario/src/android/platform/test/scenario/sleep/Idle.java b/tests/scenario/src/android/platform/test/scenario/sleep/Idle.java
index 0104de9..882523c 100644
--- a/tests/scenario/src/android/platform/test/scenario/sleep/Idle.java
+++ b/tests/scenario/src/android/platform/test/scenario/sleep/Idle.java
@@ -17,10 +17,10 @@
 package android.platform.test.scenario.sleep;
 
 import android.os.SystemClock;
+import android.platform.test.option.LongOption;
 import android.platform.test.scenario.annotation.Scenario;
-import androidx.test.InstrumentationRegistry;
 
-import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -31,27 +31,10 @@
 @Scenario
 @RunWith(JUnit4.class)
 public class Idle {
-    private static final String DURATION_OPTION = "durationMs";
-    private static final String DURATION_DEFAULT = "1000";
-
-    private long mDurationMs = 0L;
-
-    @Before
-    public void setUp() {
-        String durationMsString = InstrumentationRegistry.getArguments()
-                .getString(DURATION_OPTION, DURATION_DEFAULT);
-        try {
-            mDurationMs = Long.parseLong(durationMsString);
-        } catch (NumberFormatException e) {
-            throw new IllegalArgumentException(
-                    String.format(
-                            "Failed to parse option %s: %s", DURATION_OPTION, durationMsString));
-        }
-
-    }
+    @Rule public final LongOption mDurationMs = new LongOption("durationMs").setDefault(1000L);
 
     @Test
     public void testDoingNothing() {
-        SystemClock.sleep(mDurationMs);
+        SystemClock.sleep(mDurationMs.get());
     }
 }
diff --git a/tests/scenario/src/android/platform/test/scenario/system/ScreenOff.java b/tests/scenario/src/android/platform/test/scenario/system/ScreenOff.java
index 997b665..cf6ea9e 100644
--- a/tests/scenario/src/android/platform/test/scenario/system/ScreenOff.java
+++ b/tests/scenario/src/android/platform/test/scenario/system/ScreenOff.java
@@ -18,11 +18,13 @@
 
 import android.os.RemoteException;
 import android.os.SystemClock;
+import android.platform.test.option.LongOption;
 import android.platform.test.scenario.annotation.Scenario;
 import android.support.test.uiautomator.UiDevice;
 import androidx.test.InstrumentationRegistry;
 
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -33,30 +35,19 @@
 @Scenario
 @RunWith(JUnit4.class)
 public class ScreenOff {
-    private static final String DURATION_OPTION = "screenOffDurationMs";
-    private static final String DURATION_DEFAULT = "1000";
+    @Rule
+    public final LongOption mDurationMs = new LongOption("screenOffDurationMs").setDefault(1000L);
 
-    private long mDurationMs = 0L;
     private UiDevice mDevice;
 
     @Before
     public void setUp() {
         mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
-        String durationMsString = InstrumentationRegistry.getArguments()
-                .getString(DURATION_OPTION, DURATION_DEFAULT);
-        try {
-            mDurationMs = Long.parseLong(durationMsString);
-        } catch (NumberFormatException e) {
-            throw new IllegalArgumentException(
-                    String.format(
-                            "Failed to parse option %s: %s", DURATION_OPTION, durationMsString));
-        }
-
     }
 
     @Test
     public void testScreenOff() throws RemoteException {
         mDevice.sleep();
-        SystemClock.sleep(mDurationMs);
+        SystemClock.sleep(mDurationMs.get());
     }
 }
diff --git a/tests/scenario/src/android/platform/test/scenario/system/ScreenOn.java b/tests/scenario/src/android/platform/test/scenario/system/ScreenOn.java
index 103f854..24c1461 100644
--- a/tests/scenario/src/android/platform/test/scenario/system/ScreenOn.java
+++ b/tests/scenario/src/android/platform/test/scenario/system/ScreenOn.java
@@ -18,11 +18,13 @@
 
 import android.os.RemoteException;
 import android.os.SystemClock;
+import android.platform.test.option.LongOption;
 import android.platform.test.scenario.annotation.Scenario;
 import android.support.test.uiautomator.UiDevice;
 import androidx.test.InstrumentationRegistry;
 
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -33,30 +35,18 @@
 @Scenario
 @RunWith(JUnit4.class)
 public class ScreenOn {
-    private static final String DURATION_OPTION = "screenOnDurationMs";
-    private static final String DURATION_DEFAULT = "1000";
+    @Rule public LongOption mDurationMs = new LongOption("screenOnDurationMs").setDefault(1000L);
 
-    private long mDurationMs = 0L;
     private UiDevice mDevice;
 
     @Before
     public void setUp() {
         mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
-        String durationMsString = InstrumentationRegistry.getArguments()
-                .getString(DURATION_OPTION, DURATION_DEFAULT);
-        try {
-            mDurationMs = Long.parseLong(durationMsString);
-        } catch (NumberFormatException e) {
-            throw new IllegalArgumentException(
-                    String.format(
-                            "Failed to parse option %s: %s", DURATION_OPTION, durationMsString));
-        }
-
     }
 
     @Test
     public void testScreenOn() throws RemoteException {
         mDevice.wakeUp();
-        SystemClock.sleep(mDurationMs);
+        SystemClock.sleep(mDurationMs.get());
     }
 }
diff --git a/tests/scenario/tests/Android.bp b/tests/scenario/tests/Android.bp
index 917e64f..a597b92 100644
--- a/tests/scenario/tests/Android.bp
+++ b/tests/scenario/tests/Android.bp
@@ -64,6 +64,7 @@
         "longevity-device-lib",
         "microbenchmark-device-lib",
         "platform-test-composers",
+        "platform-test-options",
         "platform-test-rules",
     ],
     srcs: ["src/**/*.java"],