move nose tests to unittest
diff --git a/Jenkinsfile b/Jenkinsfile
index 89dd58da4d3b20..9dfdc36e8b7c16 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -170,8 +170,8 @@ pipeline {
                   steps {
                     phone_steps("eon", [
                       ["build", "cd selfdrive/manager && ./build.py"],
-                      ["test sounds", "nosetests -s selfdrive/ui/tests/test_sounds.py"],
-                      ["test boardd loopback", "nosetests -s selfdrive/boardd/tests/test_boardd_loopback.py"],
+                      ["test sounds", "python selfdrive/ui/tests/test_soundd.py"],
+                      ["test boardd loopback", "python selfdrive/boardd/tests/test_boardd_loopback.py"],
                       ["test loggerd", "python selfdrive/loggerd/tests/test_loggerd.py"],
                       ["test encoder", "python selfdrive/loggerd/tests/test_encoder.py"],
                       ["test logcatd", "python selfdrive/logcatd/tests/test_logcatd_android.py"],
diff --git a/selfdrive/boardd/tests/test_boardd_loopback.py b/selfdrive/boardd/tests/test_boardd_loopback.py
index 21b7a7ced6d3dc..f77ba6489ddb0d 100755
--- a/selfdrive/boardd/tests/test_boardd_loopback.py
+++ b/selfdrive/boardd/tests/test_boardd_loopback.py
@@ -2,96 +2,104 @@
 import os
 import random
 import time
+import unittest
 from collections import defaultdict
 from functools import wraps
 
 import cereal.messaging as messaging
 from cereal import car
-from common.basedir import BASEDIR
 from common.params import Params
 from common.spinner import Spinner
 from common.timeout import Timeout
 from panda import Panda
 from selfdrive.boardd.boardd import can_list_to_can_capnp
 from selfdrive.car import make_can_msg
-from selfdrive.test.helpers import with_processes
+from selfdrive.test.helpers import phone_only, with_processes
 
 
-def reset_panda(fn):
-  @wraps(fn)
-  def wrapper():
+def reset_panda(f):
+  @wraps(f)
+  def wrapper(*args, **kwargs):
     p = Panda()
     for i in [0, 1, 2, 0xFFFF]:
       p.can_clear(i)
     p.reset()
     p.close()
-    fn()
+    f(*args, **kwargs)
   return wrapper
 
 os.environ['STARTED'] = '1'
 os.environ['BOARDD_LOOPBACK'] = '1'
-os.environ['BASEDIR'] = BASEDIR
-
-@reset_panda
-@with_processes(['pandad'])
-def test_boardd_loopback():
-  # wait for boardd to init
-  spinner = Spinner()
-  time.sleep(2)
-
-  with Timeout(60, "boardd didn't start"):
-    sm = messaging.SubMaster(['pandaStates'])
-    while sm.rcv_frame['pandaStates'] < 1:
-      sm.update(1000)
-
-  # boardd blocks on CarVin and CarParams
-  cp = car.CarParams.new_message()
-
-  safety_config = car.CarParams.SafetyConfig.new_message()
-  safety_config.safetyModel = car.CarParams.SafetyModel.allOutput
-  cp.safetyConfigs = [safety_config]
-
-  Params().put("CarVin", b"0"*17)
-  Params().put_bool("ControlsReady", True)
-  Params().put("CarParams", cp.to_bytes())
-
-  sendcan = messaging.pub_sock('sendcan')
-  can = messaging.sub_sock('can', conflate=False, timeout=100)
-
-  time.sleep(1)
-
-  n = 1000
-  for i in range(n):
-    spinner.update(f"boardd loopback {i}/{n}")
-
-    sent_msgs = defaultdict(set)
-    for _ in range(random.randrange(10)):
-      to_send = []
-      for __ in range(random.randrange(100)):
-        bus = random.randrange(3)
-        addr = random.randrange(1, 1<<29)
-        dat = bytes([random.getrandbits(8) for _ in range(random.randrange(1, 9))])
-        sent_msgs[bus].add((addr, dat))
-        to_send.append(make_can_msg(addr, dat, bus))
-      sendcan.send(can_list_to_can_capnp(to_send, msgtype='sendcan'))
-
-    max_recv = 10
-    while max_recv > 0 and any(len(sent_msgs[bus]) for bus in range(3)):
-      recvd = messaging.drain_sock(can, wait_for_one=True)
-      for msg in recvd:
-        for m in msg.can:
-          if m.src >= 128:
-            k = (m.address, m.dat)
-            assert k in sent_msgs[m.src-128]
-            sent_msgs[m.src-128].discard(k)
-      max_recv -= 1
-
-    # if a set isn't empty, messages got dropped
-    for bus in range(3):
-      assert not len(sent_msgs[bus]), f"loop {i}: bus {bus} missing {len(sent_msgs[bus])} messages"
-
-  spinner.close()
+
+class TestBoardd(unittest.TestCase):
+
+  @classmethod
+  def setUpClass(cls):
+    cls.spinner = Spinner()
+
+  @classmethod
+  def tearDownClass(cls):
+    cls.spinner.close()
+
+  @phone_only
+  @reset_panda
+  @with_processes(['pandad'])
+  def test_loopback(self):
+    # wait for boardd to init
+    time.sleep(2)
+
+    with Timeout(60, "boardd didn't start"):
+      sm = messaging.SubMaster(['pandaStates'])
+      while sm.rcv_frame['pandaStates'] < 1:
+        sm.update(1000)
+
+    # boardd blocks on CarVin and CarParams
+    cp = car.CarParams.new_message()
+
+    safety_config = car.CarParams.SafetyConfig.new_message()
+    safety_config.safetyModel = car.CarParams.SafetyModel.allOutput
+    cp.safetyConfigs = [safety_config]
+
+    params = Params()
+    params.put("CarVin", b"0"*17)
+    params.put_bool("ControlsReady", True)
+    params.put("CarParams", cp.to_bytes())
+
+    sendcan = messaging.pub_sock('sendcan')
+    can = messaging.sub_sock('can', conflate=False, timeout=100)
+
+    time.sleep(1)
+
+    n = 1000
+    for i in range(n):
+      self.spinner.update(f"boardd loopback {i}/{n}")
+
+      sent_msgs = defaultdict(set)
+      for _ in range(random.randrange(10)):
+        to_send = []
+        for __ in range(random.randrange(100)):
+          bus = random.randrange(3)
+          addr = random.randrange(1, 1<<29)
+          dat = bytes([random.getrandbits(8) for _ in range(random.randrange(1, 9))])
+          sent_msgs[bus].add((addr, dat))
+          to_send.append(make_can_msg(addr, dat, bus))
+        sendcan.send(can_list_to_can_capnp(to_send, msgtype='sendcan'))
+
+      max_recv = 10
+      while max_recv > 0 and any(len(sent_msgs[bus]) for bus in range(3)):
+        recvd = messaging.drain_sock(can, wait_for_one=True)
+        for msg in recvd:
+          for m in msg.can:
+            if m.src >= 128:
+              k = (m.address, m.dat)
+              assert k in sent_msgs[m.src-128]
+              sent_msgs[m.src-128].discard(k)
+        max_recv -= 1
+
+      # if a set isn't empty, messages got dropped
+      for bus in range(3):
+        assert not len(sent_msgs[bus]), f"loop {i}: bus {bus} missing {len(sent_msgs[bus])} messages"
 
 
 if __name__ == "__main__":
-    test_boardd_loopback()
+  unittest.main()
diff --git a/selfdrive/test/helpers.py b/selfdrive/test/helpers.py
index 6a7c1a837800c2..b227abd16824d6 100644
--- a/selfdrive/test/helpers.py
+++ b/selfdrive/test/helpers.py
@@ -1,10 +1,9 @@
 import time
 from functools import wraps
-from nose.tools import nottest
 
 from selfdrive.hardware import PC
-from selfdrive.version import training_version, terms_version
 from selfdrive.manager.process_config import managed_processes
+from selfdrive.version import training_version, terms_version
 
 
 def set_params_enabled():
@@ -17,11 +16,13 @@ def set_params_enabled():
   params.put_bool("Passive", False)
 
 
-def phone_only(x):
-  if PC:
-    return nottest(x)
-  else:
-    return x
+def phone_only(f):
+  @wraps(f)
+  def wrap(self, *args, **kwargs):
+    if PC:
+      self.skipTest("This test is not meant to run on PC")
+    f(self, *args, **kwargs)
+  return wrap
 
 
 def with_processes(processes, init_time=0, ignore_stopped=None):
diff --git a/selfdrive/ui/tests/test_soundd.py b/selfdrive/ui/tests/test_soundd.py
new file mode 100755
index 00000000000000..80302faa9a9c1b
--- /dev/null
+++ b/selfdrive/ui/tests/test_soundd.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+import subprocess
+import time
+import unittest
+
+from cereal import log, car
+import cereal.messaging as messaging
+from selfdrive.test.helpers import phone_only, with_processes
+# TODO: rewrite for unittest
+from common.realtime import DT_CTRL
+from selfdrive.hardware import HARDWARE
+
+AudibleAlert = car.CarControl.HUDControl.AudibleAlert
+
+SOUNDS = {
+  # sound: total writes
+  AudibleAlert.none: 0,
+  AudibleAlert.chimeEngage: 173,
+  AudibleAlert.chimeDisengage: 173,
+  AudibleAlert.chimeError: 173,
+  AudibleAlert.chimePrompt: 173,
+  AudibleAlert.chimeWarning1: 163,
+  AudibleAlert.chimeWarning2: 216,
+  AudibleAlert.chimeWarning2Repeat: 470,
+  AudibleAlert.chimeWarningRepeat: 468,
+}
+
+def get_total_writes():
+  audio_flinger = subprocess.check_output('dumpsys media.audio_flinger', shell=True, encoding='utf-8').strip()
+  write_lines = [l for l in audio_flinger.split('\n') if l.strip().startswith('Total writes')]
+  return sum([int(l.split(':')[1]) for l in write_lines])
+
+class TestSoundd(unittest.TestCase):
+  def test_sound_card_init(self):
+    assert HARDWARE.get_sound_card_online()
+
+  @phone_only
+  @with_processes(['soundd'])
+  def test_alert_sounds(self):
+    pm = messaging.PubMaster(['controlsState'])
+
+    # make sure they're all defined
+    alert_sounds = {v: k for k, v in car.CarControl.HUDControl.AudibleAlert.schema.enumerants.items()}
+    diff = set(SOUNDS.keys()).symmetric_difference(alert_sounds.keys())
+    assert len(diff) == 0, f"not all sounds defined in test: {diff}"
+
+    # wait for procs to init
+    time.sleep(1)
+
+    for sound, expected_writes in SOUNDS.items():
+      print(f"testing {alert_sounds[sound]}")
+      start_writes = get_total_writes()
+
+      for _ in range(int(9 / DT_CTRL)):
+        msg = messaging.new_message('controlsState')
+        msg.controlsState.alertSound = sound
+        msg.controlsState.alertType = str(sound)
+        msg.controlsState.alertText1 = "Testing Sounds"
+        msg.controlsState.alertText2 = f"playing {alert_sounds[sound]}"
+        msg.controlsState.alertSize = log.ControlsState.AlertSize.mid
+        pm.send('controlsState', msg)
+        time.sleep(DT_CTRL)
+
+      tolerance = (expected_writes % 100) * 2
+      actual_writes = get_total_writes() - start_writes
+      assert abs(expected_writes - actual_writes) <= tolerance, f"{alert_sounds[sound]}: expected {expected_writes} writes, got {actual_writes}"
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/selfdrive/ui/tests/test_sounds.py b/selfdrive/ui/tests/test_sounds.py
deleted file mode 100755
index 1f81370dc672ba..00000000000000
--- a/selfdrive/ui/tests/test_sounds.py
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env python3
-import time
-import subprocess
-
-from cereal import log, car
-import cereal.messaging as messaging
-from selfdrive.test.helpers import phone_only, with_processes
-from common.realtime import DT_CTRL
-from selfdrive.hardware import HARDWARE
-
-AudibleAlert = car.CarControl.HUDControl.AudibleAlert
-
-SOUNDS = {
-  # sound: total writes
-  AudibleAlert.none: 0,
-  AudibleAlert.chimeEngage: 173,
-  AudibleAlert.chimeDisengage: 173,
-  AudibleAlert.chimeError: 173,
-  AudibleAlert.chimePrompt: 173,
-  AudibleAlert.chimeWarning1: 163,
-  AudibleAlert.chimeWarning2: 216,
-  AudibleAlert.chimeWarning2Repeat: 470,
-  AudibleAlert.chimeWarningRepeat: 468,
-}
-
-def get_total_writes():
-  audio_flinger = subprocess.check_output('dumpsys media.audio_flinger', shell=True, encoding='utf-8').strip()
-  write_lines = [l for l in audio_flinger.split('\n') if l.strip().startswith('Total writes')]
-  return sum([int(l.split(':')[1]) for l in write_lines])
-
-@phone_only
-def test_sound_card_init():
-  assert HARDWARE.get_sound_card_online()
-
-
-@phone_only
-@with_processes(['soundd'])
-def test_alert_sounds():
-  pm = messaging.PubMaster(['controlsState'])
-
-  # make sure they're all defined
-  alert_sounds = {v: k for k, v in car.CarControl.HUDControl.AudibleAlert.schema.enumerants.items()}
-  diff = set(SOUNDS.keys()).symmetric_difference(alert_sounds.keys())
-  assert len(diff) == 0, f"not all sounds defined in test: {diff}"
-
-  # wait for procs to init
-  time.sleep(1)
-
-  for sound, expected_writes in SOUNDS.items():
-    print(f"testing {alert_sounds[sound]}")
-    start_writes = get_total_writes()
-
-    for _ in range(int(9 / DT_CTRL)):
-      msg = messaging.new_message('controlsState')
-      msg.controlsState.alertSound = sound
-      msg.controlsState.alertType = str(sound)
-      msg.controlsState.alertText1 = "Testing Sounds"
-      msg.controlsState.alertText2 = f"playing {alert_sounds[sound]}"
-      msg.controlsState.alertSize = log.ControlsState.AlertSize.mid
-      pm.send('controlsState', msg)
-      time.sleep(DT_CTRL)
-
-    tolerance = (expected_writes % 100) * 2
-    actual_writes = get_total_writes() - start_writes
-    assert abs(expected_writes - actual_writes) <= tolerance, f"{alert_sounds[sound]}: expected {expected_writes} writes, got {actual_writes}"
