Support VirtualEnv and parameterized tests

This commit introduces the use of VirtualEnv to manage test
dependencies. Specifically:
  1. `PythonVirtualenvPreparer` is invoked before running tests
     to set up the isolated Python environment.
  2. The test is declared as a `MoblyBinaryHostTest` to enable
     VirtualEnv compatibility, as `PythonBinaryHostTest` lacks
     this support.
  3. The Mobly test runner is invoked manually to accommodate
     multiple test classes, which is not supported by
     `mobly.test_runner.main()`.
  4. Test classes are refactored to inherit from
     `mobly.BaseTestClass` for compatibility with the Mobly
     test runner.

This change is a prerequisite for subsequent commits that will
install libraries via `pip install`, facilitating isolated
dependency management for improved test reliability and
maintainability.

Also, this commit uses parameterized annotation from absl
to rewrite several tests as paremeterized tests.

Test: atest NetworkStaticLibHostPythonTests
Bug: 335368434
Change-Id: Iffa2f8fa81c3913f3e08cca01b2c009ca959db73
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index e9f8858..3186033 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -76,7 +76,14 @@
     test_suites: [
         "general-tests",
     ],
+    // MoblyBinaryHostTest doesn't support unit_test.
     test_options: {
-        unit_test: true,
+        unit_test: false,
+    },
+    // Needed for applying VirtualEnv.
+    version: {
+        py3: {
+            embedded_launcher: false,
+        },
     },
 }
diff --git a/staticlibs/tests/unit/host/python/adb_utils_test.py b/staticlibs/tests/unit/host/python/adb_utils_test.py
index b75d464..8fcca37 100644
--- a/staticlibs/tests/unit/host/python/adb_utils_test.py
+++ b/staticlibs/tests/unit/host/python/adb_utils_test.py
@@ -12,15 +12,21 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
-import unittest
 from unittest.mock import MagicMock, patch
+from absl.testing import parameterized
+from mobly import asserts
+from mobly import base_test
+from mobly import config_parser
 from net_tests_utils.host.python import adb_utils
 from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError
 
 
-class TestAdbUtils(unittest.TestCase):
+class TestAdbUtils(base_test.BaseTestClass, parameterized.TestCase):
 
-  def setUp(self):
+  def __init__(self, configs: config_parser.TestRunConfig):
+    super().__init__(configs)
+
+  def setup_test(self):
     self.mock_ad = MagicMock()  # Mock Android device object
     self.mock_ad.log = MagicMock()
     self.mock_ad.adb.shell.return_value = b""  # Default empty return for shell
@@ -49,25 +55,21 @@
   @patch("net_tests_utils.host.python.adb_utils._get_screen_state")
   def test_set_screen_state_failure(self, mock_get_screen_state):
     mock_get_screen_state.return_value = False  # State doesn't change
-    with self.assertRaises(UnexpectedBehaviorError):
+    with asserts.assert_raises(UnexpectedBehaviorError):
       adb_utils._set_screen_state(self.mock_ad, True)
 
+  @parameterized.parameters(
+      ("Awake", True),
+      ("Asleep", False),
+      ("Dozing", False),
+      ("SomeOtherState", False),
+  )  # Declare inputs for state_str and expected_result.
   @patch("net_tests_utils.host.python.adb_utils.get_value_of_key_from_dumpsys")
-  def test_get_screen_state(self, mock_get_value):
-    # Test cases for different return values of get_value_of_key_from_dumpsys
-    # TODO: Make it parameterized.
-    test_cases = [
-        ("Awake", True),
-        ("Asleep", False),
-        ("Dozing", False),
-        ("SomeOtherState", False),
-    ]
-
-    for state_str, expected_result in test_cases:
-      mock_get_value.return_value = state_str
-      self.assertEqual(
-          adb_utils._get_screen_state(self.mock_ad), expected_result
-      )
+  def test_get_screen_state(self, state_str, expected_result, mock_get_value):
+    mock_get_value.return_value = state_str
+    asserts.assert_equal(
+        adb_utils._get_screen_state(self.mock_ad), expected_result
+    )
 
   def test_get_value_of_key_from_dumpsys(self):
     self.mock_ad.adb.shell.return_value = (
@@ -76,36 +78,34 @@
     result = adb_utils.get_value_of_key_from_dumpsys(
         self.mock_ad, "power", "mWakefulness"
     )
-    self.assertEqual(result, "Awake")
+    asserts.assert_equal(result, "Awake")
 
+  @parameterized.parameters(
+      (True, ["true"]),
+      (False, ["false"]),
+      (
+          True,
+          ["false", "true"],
+      ),  # Expect True, get False which is unexpected, then get True
+      (
+          False,
+          ["true", "false"],
+      ),  # Expect False, get True which is unexpected, then get False
+  )  # Declare inputs for expected_state and returned_value
   @patch("net_tests_utils.host.python.adb_utils.get_value_of_key_from_dumpsys")
-  def test_expect_dumpsys_state_with_retry_success(self, mock_get_value):
-    # Test cases for different combinations of expected_state and get_value return value
-    # TODO: Make it parameterized.
-    test_cases = [
-        (True, ["true"]),  # Expect True, get True
-        (False, ["false"]),  # Expect False, get False
-        (
-            True,
-            ["false", "true"],
-        ),  # Expect True, get False which is unexpected, then get True
-        (
-            False,
-            ["true", "false"],
-        ),  # Expect False, get True which is unexpected, then get False
-    ]
-
-    for expected_state, returned_value in test_cases:
-      mock_get_value.side_effect = returned_value
-      # Verify the method returns and does not throw.
-      adb_utils.expect_dumpsys_state_with_retry(
-          self.mock_ad, "service", "key", expected_state, 0
-      )
+  def test_expect_dumpsys_state_with_retry_success(
+      self, expected_state, returned_value, mock_get_value
+  ):
+    mock_get_value.side_effect = returned_value
+    # Verify the method returns and does not throw.
+    adb_utils.expect_dumpsys_state_with_retry(
+        self.mock_ad, "service", "key", expected_state, 0
+    )
 
   @patch("net_tests_utils.host.python.adb_utils.get_value_of_key_from_dumpsys")
   def test_expect_dumpsys_state_with_retry_failure(self, mock_get_value):
     mock_get_value.return_value = "false"
-    with self.assertRaises(UnexpectedBehaviorError):
+    with asserts.assert_raises(UnexpectedBehaviorError):
       adb_utils.expect_dumpsys_state_with_retry(
           self.mock_ad, "service", "key", True, 0
       )
@@ -116,11 +116,7 @@
     mock_get_value.return_value = None
 
     # Expect the function to raise UnexpectedBehaviorError due to the exception
-    with self.assertRaises(UnexpectedBehaviorError):
+    with asserts.assert_raises(UnexpectedBehaviorError):
       adb_utils.expect_dumpsys_state_with_retry(
           self.mock_ad, "service", "key", True
       )
-
-
-if __name__ == "__main__":
-  unittest.main()
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index 619609a..50bc884 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -12,8 +12,10 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
-import unittest
 from unittest.mock import MagicMock, patch
+from mobly import asserts
+from mobly import base_test
+from mobly import config_parser
 from mobly.controllers.android_device_lib.adb import AdbError
 from net_tests_utils.host.python.apf_utils import (
     PatternNotFoundException,
@@ -27,9 +29,12 @@
 from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError
 
 
-class TestApfUtils(unittest.TestCase):
+class TestApfUtils(base_test.BaseTestClass):
 
-  def setUp(self):
+  def __init__(self, configs: config_parser.TestRunConfig):
+    super().__init__(configs)
+
+  def setup_test(self):
     self.mock_ad = MagicMock()  # Mock Android device object
 
   @patch("net_tests_utils.host.python.adb_utils.get_dumpsys_for_service")
@@ -43,7 +48,7 @@
     COUNTER_NAME2: 456
 """
     counters = get_apf_counters_from_dumpsys(self.mock_ad, "wlan0")
-    self.assertEqual(counters, {"COUNTER_NAME1": 123, "COUNTER_NAME2": 456})
+    asserts.assert_equal(counters, {"COUNTER_NAME1": 123, "COUNTER_NAME2": 456})
 
   @patch("net_tests_utils.host.python.adb_utils.get_dumpsys_for_service")
   def test_get_apf_counters_from_dumpsys_exceptions(
@@ -63,7 +68,7 @@
 
     for dumpsys_output in test_cases:
       mock_get_dumpsys.return_value = dumpsys_output
-      with self.assertRaises(PatternNotFoundException):
+      with asserts.assert_raises(PatternNotFoundException):
         get_apf_counters_from_dumpsys(self.mock_ad, "wlan0")
 
   @patch("net_tests_utils.host.python.apf_utils.get_apf_counters_from_dumpsys")
@@ -73,9 +78,13 @@
         "COUNTER_NAME1": 123,
         "COUNTER_NAME2": 456,
     }
-    self.assertEqual(get_apf_counter(self.mock_ad, iface, "COUNTER_NAME1"), 123)
+    asserts.assert_equal(
+        get_apf_counter(self.mock_ad, iface, "COUNTER_NAME1"), 123
+    )
     # Not found
-    self.assertEqual(get_apf_counter(self.mock_ad, iface, "COUNTER_NAME3"), 0)
+    asserts.assert_equal(
+        get_apf_counter(self.mock_ad, iface, "COUNTER_NAME3"), 0
+    )
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
   def test_get_hardware_address_success(
@@ -86,14 +95,14 @@
  link/ether 72:05:77:82:21:e0 brd ff:ff:ff:ff:ff:ff
 """
     mac_address = get_hardware_address(self.mock_ad, "wlan0")
-    self.assertEqual(mac_address, "72:05:77:82:21:E0")
+    asserts.assert_equal(mac_address, "72:05:77:82:21:E0")
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
   def test_get_hardware_address_not_found(
       self, mock_adb_shell: MagicMock
   ) -> None:
     mock_adb_shell.return_value = "Some output without MAC address"
-    with self.assertRaises(PatternNotFoundException):
+    with asserts.assert_raises(PatternNotFoundException):
       get_hardware_address(self.mock_ad, "wlan0")
 
   @patch("net_tests_utils.host.python.apf_utils.get_hardware_address")
@@ -133,7 +142,7 @@
     mock_adb_shell.return_value = (  # Unexpected command output
         "Any Unexpected Output"
     )
-    with self.assertRaises(UnexpectedBehaviorError):
+    with asserts.assert_raises(UnexpectedBehaviorError):
       send_raw_packet_downstream(
           self.mock_ad, "BEEF", "eth0", "1234567890AB", "AABBCCDDEEFF"
       )
@@ -145,11 +154,7 @@
     mock_adb_shell.side_effect = AdbError(
         cmd="", stdout="Unknown command", stderr="", ret_code=3
     )
-    with self.assertRaises(UnsupportedOperationException):
+    with asserts.assert_raises(UnsupportedOperationException):
       send_raw_packet_downstream(
           self.mock_ad, "BEEF", "eth0", "1234567890AB", "AABBCCDDEEFF"
       )
-
-
-if __name__ == "__main__":
-  unittest.main()
diff --git a/staticlibs/tests/unit/host/python/assert_utils_test.py b/staticlibs/tests/unit/host/python/assert_utils_test.py
index b970d14..7a33373 100644
--- a/staticlibs/tests/unit/host/python/assert_utils_test.py
+++ b/staticlibs/tests/unit/host/python/assert_utils_test.py
@@ -12,11 +12,12 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
-import unittest
+from mobly import asserts
+from mobly import base_test
 from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError, expect_with_retry
 
 
-class TestAssertUtils(unittest.TestCase):
+class TestAssertUtils(base_test.BaseTestClass):
 
   def test_predicate_succeed(self):
     """Test when the predicate becomes True within retries."""
@@ -28,12 +29,12 @@
       return call_count > 2  # True on the third call
 
     expect_with_retry(predicate, max_retries=5, retry_interval_sec=0)
-    self.assertEqual(call_count, 3)  # Ensure it was called exactly 3 times
+    asserts.assert_equal(call_count, 3)  # Ensure it was called exactly 3 times
 
   def test_predicate_failed(self):
     """Test when the predicate never becomes True."""
 
-    with self.assertRaises(UnexpectedBehaviorError):
+    with asserts.assert_raises(UnexpectedBehaviorError):
       expect_with_retry(
           predicate=lambda: False, max_retries=3, retry_interval_sec=0
       )
@@ -52,7 +53,9 @@
         max_retries=5,
         retry_interval_sec=0,
     )
-    self.assertFalse(retry_action_called)  # Assert retry_action was NOT called
+    asserts.assert_false(
+        retry_action_called, "retry_action called."
+    )  # Assert retry_action was NOT called
 
   def test_retry_action_not_called_failed(self):
     """Test that the retry_action is not called if the max_retries is reached."""
@@ -62,14 +65,16 @@
       nonlocal retry_action_called
       retry_action_called = True
 
-    with self.assertRaises(UnexpectedBehaviorError):
+    with asserts.assert_raises(UnexpectedBehaviorError):
       expect_with_retry(
           predicate=lambda: False,
           retry_action=retry_action,
           max_retries=1,
           retry_interval_sec=0,
       )
-    self.assertFalse(retry_action_called)  # Assert retry_action was NOT called
+    asserts.assert_false(
+        retry_action_called, "retry_action called."
+    )  # Assert retry_action was NOT called
 
   def test_retry_action_called(self):
     """Test that the retry_action is executed when provided."""
@@ -79,11 +84,11 @@
       nonlocal retry_action_called
       retry_action_called = True
 
-    with self.assertRaises(UnexpectedBehaviorError):
+    with asserts.assert_raises(UnexpectedBehaviorError):
       expect_with_retry(
           predicate=lambda: False,
           retry_action=retry_action,
           max_retries=2,
           retry_interval_sec=0,
       )
-    self.assertTrue(retry_action_called)
+    asserts.assert_true(retry_action_called, "retry_action not called.")
diff --git a/staticlibs/tests/unit/host/python/run_tests.py b/staticlibs/tests/unit/host/python/run_tests.py
index c44f1a8..fa6a310 100644
--- a/staticlibs/tests/unit/host/python/run_tests.py
+++ b/staticlibs/tests/unit/host/python/run_tests.py
@@ -14,14 +14,22 @@
 
 """Main entrypoint for all of unittest."""
 
-import unittest
-
-# Import all unittest classes here, so it can be discovered by unittest module.
-# TODO: make the tests can be executed without manually import classes.
+import sys
 from host.python.adb_utils_test import TestAdbUtils
 from host.python.apf_utils_test import TestApfUtils
 from host.python.assert_utils_test import TestAssertUtils
+from mobly import suite_runner
 
 
 if __name__ == "__main__":
-  unittest.main()
+  # For MoblyBinaryHostTest, this entry point will be called twice:
+  # 1. List tests.
+  #   <mobly-par-file-name> -- --list_tests
+  # 2. Run tests.
+  #   <mobly-par-file-name> -- --config=<yaml-path> --device_serial=<device-serial> --log_path=<log-path>
+  # Strip the "--" since suite runner doesn't recognize it.
+  sys.argv.pop(1)
+  # TODO: make the tests can be executed without manually list classes.
+  suite_runner.run_suite(
+      [TestAssertUtils, TestAdbUtils, TestApfUtils], sys.argv
+  )
diff --git a/staticlibs/tests/unit/host/python/test_config.xml b/staticlibs/tests/unit/host/python/test_config.xml
index 26ee9e2..d3b200a 100644
--- a/staticlibs/tests/unit/host/python/test_config.xml
+++ b/staticlibs/tests/unit/host/python/test_config.xml
@@ -14,9 +14,11 @@
      limitations under the License.
 -->
 <configuration description="Config for NetworkStaticLibHostPythonTests">
-    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
-    <test class="com.android.tradefed.testtype.python.PythonBinaryHostTest">
-        <option name="par-file-name" value="NetworkStaticLibHostPythonTests" />
-        <option name="test-timeout" value="3m" />
+    <target_preparer class="com.android.tradefed.targetprep.PythonVirtualenvPreparer">
+        <option name="dep-module" value="absl-py" />
+    </target_preparer>
+    <test class="com.android.tradefed.testtype.mobly.MoblyBinaryHostTest" >
+        <option name="mobly-par-file-name" value="NetworkStaticLibHostPythonTests" />
+        <option name="mobly-test-timeout" value="3m" />
     </test>
 </configuration>