Repository: MycroftAI/mycroft-core

Issue:
Add basic testcases for the audioservice interface
Add tests for the [audioservice](https://github.com/MycroftAI/mycroft-core/blob/dev/mycroft/skills/audioservice.py) interface for skills.

Basically the tests need to create a mock bus and create the audioservice with

```python
bus_mock = Mock()`
as = AudioService(bus_mock)
```

Then do calls against the objects methods and ensure that the expected messages are sent on the mock_bus.

If there are questions just comment here or join [our chat](https://chat.mycroft.ai)! 


Discussion:
I'd like to work on this please.
Awesome, thanks Lawrence.

Let us know if you have any questions :slightly_smiling_face: 
You're very welcome to take it on! Also since we require a CLA to accept the PR's you might want to sign it right away so you don't need to wait for it later. go to https://mycroft.ai/cla and sign up there. You'll get an e-mail fairly soon.

Many thanks for helping out!
I would like to work on this.
Can anyone help me understand how and where can I start contributing ? 
P.S -I'm new to open source
Hi @bolt25, you're very welcome to contribute!

First step would be to follow the install mycroft (linux on your computer or in a virtual machine) and make sure you can run the testcases with

```
source venv-activate.sh
pytest test/unittests/
```

Then you will need to add a new file called `test_audioservice.py` in `test/unittests/skills/` and add create testcases there. (you can see the other files in the folder for reference, the audioservice is fairly standalone and won't require the same amount of weird mocking techniques though, hopefully being much simpler).

Something like this should be the general structure of the tests:

```python
from unittest import TestCase
from unittest.mock import MagicMock


class TestAudioService(TestCase):
    def setUp(self):
        self.bus = MagicMock()
        self.audioservice = AudioService(self.bus)

    def test_play(self):
        self.audioservice.play('http://example.com/file.mp3')
        emitted_message = self.bus.emit.call_arg_list[0][0]
        self.assertEqual(emitted_message.type, 'mycroft.audio.service.play')
        self.assertEqual(emitted_message.data, EXPECTED_DATA) # expected data needs to be figured out and added

    def text_other_test_case(self):
        ...
```

Resolving patch:
diff --git a/mycroft/skills/audioservice.py b/mycroft/skills/audioservice.py
--- a/mycroft/skills/audioservice.py
+++ b/mycroft/skills/audioservice.py
@@ -12,21 +12,19 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-import time
 from os.path import abspath
 
 from mycroft.messagebus.message import Message
 
 
 def ensure_uri(s):
-    """
-        Interprete paths as file:// uri's
+    """Interprete paths as file:// uri's.
 
-        Args:
-            s: string to be checked
+    Arguments:
+        s: string to be checked
 
-        Returns:
-            if s is uri, s is returned otherwise file:// is prepended
+    Returns:
+        if s is uri, s is returned otherwise file:// is prepended
     """
     if isinstance(s, str):
         if '://' not in s:
@@ -43,33 +41,25 @@ def ensure_uri(s):
 
 
 class AudioService:
-    """
-        AudioService class for interacting with the audio subsystem
+    """AudioService class for interacting with the audio subsystem
 
-        Arguments:
-            bus: Mycroft messagebus connection
+    Arguments:
+        bus: Mycroft messagebus connection
     """
 
     def __init__(self, bus):
         self.bus = bus
-        self.bus.on('mycroft.audio.service.track_info_reply',
-                    self._track_info)
-        self.info = None
-
-    def _track_info(self, message=None):
-        """
-            Handler for catching returning track info
-        """
-        self.info = message.data
 
     def queue(self, tracks=None):
-        """ Queue up a track to playing playlist.
+        """Queue up a track to playing playlist.
 
-            Args:
-                tracks: track uri or list of track uri's
+        Arguments:
+            tracks: track uri or list of track uri's
+                    Each track can be added as a tuple with (uri, mime)
+                    to give a hint of the mime type to the system
         """
         tracks = tracks or []
-        if isinstance(tracks, str):
+        if isinstance(tracks, (str, tuple)):
             tracks = [tracks]
         elif not isinstance(tracks, list):
             raise ValueError
@@ -78,15 +68,15 @@ def queue(self, tracks=None):
                               data={'tracks': tracks}))
 
     def play(self, tracks=None, utterance=None, repeat=None):
-        """ Start playback.
-
-            Args:
-                tracks: track uri or list of track uri's
-                        Each track can be added as a tuple with (uri, mime)
-                        to give a hint of the mime type to the system
-                utterance: forward utterance for further processing by the
-                           audio service.
-                repeat: if the playback should be looped
+        """Start playback.
+
+        Arguments:
+            tracks: track uri or list of track uri's
+                    Each track can be added as a tuple with (uri, mime)
+                    to give a hint of the mime type to the system
+            utterance: forward utterance for further processing by the
+                        audio service.
+            repeat: if the playback should be looped
         """
         repeat = repeat or False
         tracks = tracks or []
@@ -102,31 +92,30 @@ def play(self, tracks=None, utterance=None, repeat=None):
                                     'repeat': repeat}))
 
     def stop(self):
-        """ Stop the track. """
+        """Stop the track."""
         self.bus.emit(Message('mycroft.audio.service.stop'))
 
     def next(self):
-        """ Change to next track. """
+        """Change to next track."""
         self.bus.emit(Message('mycroft.audio.service.next'))
 
     def prev(self):
-        """ Change to previous track. """
+        """Change to previous track."""
         self.bus.emit(Message('mycroft.audio.service.prev'))
 
     def pause(self):
-        """ Pause playback. """
+        """Pause playback."""
         self.bus.emit(Message('mycroft.audio.service.pause'))
 
     def resume(self):
-        """ Resume paused playback. """
+        """Resume paused playback."""
         self.bus.emit(Message('mycroft.audio.service.resume'))
 
     def seek(self, seconds=1):
-        """
-        seek X seconds
+        """Seek X seconds.
 
-         Args:
-                seconds (int): number of seconds to seek, if negative rewind
+        Arguments:
+            seconds (int): number of seconds to seek, if negative rewind
         """
         if seconds < 0:
             self.seek_backward(abs(seconds))
@@ -134,42 +123,37 @@ def seek(self, seconds=1):
             self.seek_forward(seconds)
 
     def seek_forward(self, seconds=1):
-        """
-        skip ahead X seconds
+        """Skip ahead X seconds.
 
-         Args:
-                seconds (int): number of seconds to skip
+        Arguments:
+            seconds (int): number of seconds to skip
         """
         self.bus.emit(Message('mycroft.audio.service.seek_forward',
                               {"seconds": seconds}))
 
     def seek_backward(self, seconds=1):
-        """
-        rewind X seconds
+        """Rewind X seconds
 
-         Args:
-                seconds (int): number of seconds to rewind
+         Arguments:
+            seconds (int): number of seconds to rewind
         """
         self.bus.emit(Message('mycroft.audio.service.seek_backward',
                               {"seconds": seconds}))
 
     def track_info(self):
-        """ Request information of current playing track.
+        """Request information of current playing track.
 
-            Returns:
-                Dict with track info.
+        Returns:
+            Dict with track info.
         """
-        self.info = None
-        self.bus.emit(Message('mycroft.audio.service.track_info'))
-        wait = 5.0
-        while self.info is None and wait >= 0:
-            time.sleep(0.1)
-            wait -= 0.1
-
-        return self.info or {}
+        info = self.bus.wait_for_response(
+            Message('mycroft.audio.service.track_info'),
+            reply_type='mycroft.audio.service.track_info_reply',
+            timeout=5)
+        return info.data if info else {}
 
     def available_backends(self):
-        """ Return available audio backends.
+        """Return available audio backends.
 
         Returns:
             dict with backend names as keys
@@ -180,4 +164,5 @@ def available_backends(self):
 
     @property
     def is_playing(self):
+        """True if the audioservice is playing, else False."""
         return self.track_info() != {}
diff --git a/mycroft/skills/common_play_skill.py b/mycroft/skills/common_play_skill.py
--- a/mycroft/skills/common_play_skill.py
+++ b/mycroft/skills/common_play_skill.py
@@ -52,7 +52,8 @@ def __init__(self, name=None, bus=None):
         # with a translatable name in their initialize() method.
 
     def bind(self, bus):
-        """ Overrides the normal bind method.
+        """Overrides the normal bind method.
+
         Adds handlers for play:query and play:start messages allowing
         interaction with the playback control skill.
 
@@ -66,6 +67,7 @@ def bind(self, bus):
             self.add_event('play:start', self.__handle_play_start)
 
     def __handle_play_query(self, message):
+        """Query skill if it can start playback from given phrase."""
         search_phrase = message.data["phrase"]
 
         # First, notify the requestor that we are attempting to handle
@@ -94,11 +96,19 @@ def __handle_play_query(self, message):
                                             "searching": False}))
 
     def __calc_confidence(self, match, phrase, level):
-        # "play pandora"
-        # "play pandora is my girlfriend"
-        # "play tom waits on pandora"
+        """Translate confidence level and match to a 0-1 value.
+
+        "play pandora"
+        "play pandora is my girlfriend"
+        "play tom waits on pandora"
+
+        Assume the more of the words that get consumed, the better the match
 
-        # Assume the more of the words that get consumed, the better the match
+        Arguments:
+            match (str): Matching string
+            phrase (str): original input phrase
+            level (CPSMatchLevel): match level
+        """
         consumed_pct = len(match.split()) / len(phrase.split())
         if consumed_pct > 1.0:
             consumed_pct = 1.0 / consumed_pct  # deal with over/under-matching
@@ -123,6 +133,7 @@ def __calc_confidence(self, match, phrase, level):
             return 0.0  # should never happen
 
     def __handle_play_start(self, message):
+        """Bus handler for starting playback using the skill."""
         if message.data["skill_id"] != self.skill_id:
             # Not for this skill!
             return
@@ -142,8 +153,7 @@ def __handle_play_start(self, message):
         self.CPS_start(phrase, data)
 
     def CPS_play(self, *args, **kwargs):
-        """
-        Begin playback of a media file or stream
+        """Begin playback of a media file or stream
 
         Normally this method will be invoked with somthing like:
            self.CPS_play(url)
@@ -160,6 +170,7 @@ def CPS_play(self, *args, **kwargs):
         self.audioservice.play(*args, **kwargs)
 
     def stop(self):
+        """Stop anything playing on the audioservice."""
         if self.audioservice.is_playing:
             self.audioservice.stop()
             return True
@@ -172,11 +183,9 @@ def stop(self):
     # act as a CommonPlay Skill
     @abstractmethod
     def CPS_match_query_phrase(self, phrase):
-        """
-        Analyze phrase to see if it is a play-able phrase with this
-        skill.
+        """Analyze phrase to see if it is a play-able phrase with this skill.
 
-        Args:
+        Arguments:
             phrase (str): User phrase uttered after "Play", e.g. "some music"
 
         Returns:
@@ -204,10 +213,9 @@ def CPS_match_query_phrase(self, phrase):
 
     @abstractmethod
     def CPS_start(self, phrase, data):
-        """
-        Begin playing whatever is specified in 'phrase'
+        """Begin playing whatever is specified in 'phrase'
 
-        Args:
+        Arguments:
             phrase (str): User phrase uttered after "Play", e.g. "some music"
             data (dict): Callback data specified in match_query_phrase()
         """


Test patch:
diff --git a/test/unittests/skills/test_audioservice.py b/test/unittests/skills/test_audioservice.py
new file mode 100644
--- /dev/null
+++ b/test/unittests/skills/test_audioservice.py
@@ -0,0 +1,176 @@
+from unittest import TestCase, mock
+
+from mycroft.messagebus import Message
+from mycroft.skills.audioservice import AudioService
+
+
+class TestAudioServiceControls(TestCase):
+    def assertLastMessageTypeEqual(self, bus, msg_type):
+        message = bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, msg_type)
+
+    def setUp(self):
+        self.bus = mock.Mock(name='bus')
+        self.audioservice = AudioService(self.bus)
+
+    def test_pause(self):
+        self.audioservice.pause()
+        self.assertLastMessageTypeEqual(self.bus,
+                                        'mycroft.audio.service.pause')
+
+    def test_resume(self):
+        self.audioservice.resume()
+        self.assertLastMessageTypeEqual(self.bus,
+                                        'mycroft.audio.service.resume')
+
+    def test_next(self):
+        self.audioservice.next()
+        self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.next')
+
+    def test_prev(self):
+        self.audioservice.prev()
+        self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.prev')
+
+    def test_stop(self):
+        self.audioservice.stop()
+        self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.stop')
+
+    def test_seek(self):
+        self.audioservice.seek()
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type,
+                         'mycroft.audio.service.seek_forward')
+        self.assertEqual(message.data['seconds'], 1)
+        self.audioservice.seek(5)
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type,
+                         'mycroft.audio.service.seek_forward')
+        self.assertEqual(message.data['seconds'], 5)
+        self.audioservice.seek(-5)
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type,
+                         'mycroft.audio.service.seek_backward')
+        self.assertEqual(message.data['seconds'], 5)
+
+
+class TestAudioServicePlay(TestCase):
+    def setUp(self):
+        self.bus = mock.Mock(name='bus')
+        self.audioservice = AudioService(self.bus)
+
+    def test_proper_uri(self):
+        self.audioservice.play('file:///hello_nasty.mp3')
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
+        self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
+        self.assertEqual(message.data['repeat'], False)
+
+    def test_path(self):
+        self.audioservice.play('/hello_nasty.mp3')
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
+        self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
+        self.assertEqual(message.data['repeat'], False)
+
+    def test_tuple(self):
+        """Test path together with mimetype."""
+        self.audioservice.play(('/hello_nasty.mp3', 'audio/mp3'))
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
+        self.assertEqual(message.data['tracks'],
+                         [('file:///hello_nasty.mp3', 'audio/mp3')])
+        self.assertEqual(message.data['repeat'], False)
+
+    def test_invalid(self):
+        """Test play request with invalid type."""
+        with self.assertRaises(ValueError):
+            self.audioservice.play(12)
+
+    def test_extra_arguments(self):
+        """Test sending along utterance and setting repeat."""
+        self.audioservice.play('/hello_nasty.mp3', 'on vlc', True)
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
+        self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
+        self.assertEqual(message.data['repeat'], True)
+        self.assertEqual(message.data['utterance'], 'on vlc')
+
+
+class TestAudioServiceQueue(TestCase):
+    def setUp(self):
+        self.bus = mock.Mock(name='bus')
+        self.audioservice = AudioService(self.bus)
+
+    def test_uri(self):
+        self.audioservice.queue('file:///hello_nasty.mp3')
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, 'mycroft.audio.service.queue')
+        self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
+
+    def test_path(self):
+        self.audioservice.queue('/hello_nasty.mp3')
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, 'mycroft.audio.service.queue')
+        self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
+
+    def test_tuple(self):
+        self.audioservice.queue(('/hello_nasty.mp3', 'audio/mp3'))
+        message = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(message.msg_type, 'mycroft.audio.service.queue')
+        self.assertEqual(message.data['tracks'],
+                         [('file:///hello_nasty.mp3', 'audio/mp3')])
+
+    def test_invalid(self):
+        with self.assertRaises(ValueError):
+            self.audioservice.queue(12)
+
+
+class TestAudioServiceMisc(TestCase):
+    def test_lifecycle(self):
+        bus = mock.Mock(name='bus')
+        audioservice = AudioService(bus)
+        self.assertEqual(audioservice.bus, bus)
+
+    def test_available_backends(self):
+        bus = mock.Mock(name='bus')
+        audioservice = AudioService(bus)
+
+        available_backends = {
+            'simple': {
+                'suported_uris': ['http', 'file'],
+                'default': True,
+                'remote': False
+            }
+        }
+        bus.wait_for_response.return_value = Message('test_msg',
+                                                     available_backends)
+        response = audioservice.available_backends()
+        self.assertEqual(available_backends, response)
+        # Check no response behaviour
+        bus.wait_for_response.return_value = None
+        response = audioservice.available_backends()
+        self.assertEqual({}, response)
+
+    def test_track_info(self):
+        """Test is_playing property."""
+        bus = mock.Mock(name='bus')
+        audioservice = AudioService(bus)
+        info = {'album': 'Hello Nasty',
+                'artist': 'Beastie Boys',
+                'name': 'Intergalactic'
+                }
+        bus.wait_for_response.return_value = Message('test_msg', info)
+        self.assertEqual(audioservice.track_info(), info)
+        bus.wait_for_response.return_value = None
+        self.assertEqual(audioservice.track_info(), {})
+
+    def test_is_playing(self):
+        """Test is_playing property."""
+        bus = mock.Mock(name='bus')
+        audioservice = AudioService(bus)
+        audioservice.track_info = mock.Mock()
+
+        audioservice.track_info.return_value = {'track': 'one cool song'}
+        self.assertTrue(audioservice.is_playing)
+        audioservice.track_info.return_value = {}
+        self.assertFalse(audioservice.is_playing)
diff --git a/test/unittests/skills/test_common_play_skill.py b/test/unittests/skills/test_common_play_skill.py
new file mode 100644
--- /dev/null
+++ b/test/unittests/skills/test_common_play_skill.py
@@ -0,0 +1,140 @@
+from unittest import TestCase, mock
+
+from mycroft.messagebus import Message
+from mycroft.skills.common_play_skill import CommonPlaySkill, CPSMatchLevel
+from mycroft.skills.audioservice import AudioService
+
+
+class AnyCallable:
+    """Class matching any callable.
+
+    Useful for assert_called_with arguments.
+    """
+    def __eq__(self, other):
+        return callable(other)
+
+
+class TestCommonPlay(TestCase):
+    def setUp(self):
+        self.skill = CPSTest()
+        self.bus = mock.Mock(name='bus')
+        self.skill.bind(self.bus)
+        self.audioservice = mock.Mock(name='audioservice')
+        self.skill.audioservice = self.audioservice
+
+    def test_lifecycle(self):
+        skill = CPSTest()
+        bus = mock.Mock(name='bus')
+        skill.bind(bus)
+        self.assertTrue(isinstance(skill.audioservice, AudioService))
+        bus.on.assert_any_call('play:query', AnyCallable())
+        bus.on.assert_any_call('play:start', AnyCallable())
+        skill.shutdown()
+
+    def test_handle_start_playback(self):
+        """Test common play start method."""
+        self.skill.audioservice.is_playing = True
+        start_playback = self.bus.on.call_args_list[-1][0][1]
+
+        phrase = 'Don\'t open until doomsday'
+        start_playback(Message('play:start', data={'phrase': phrase,
+                                                   'skill_id': 'asdf'}))
+        self.skill.CPS_start.assert_not_called()
+
+        self.bus.emit.reset_mock()
+        start_playback(Message('play:start',
+                               data={'phrase': phrase,
+                                     'skill_id': self.skill.skill_id}))
+        self.audioservice.stop.assert_called_once_with()
+        self.skill.CPS_start.assert_called_once_with(phrase, None)
+
+    def test_cps_play(self):
+        """Test audioservice play helper."""
+        self.skill.play_service_string = 'play on godzilla'
+        self.skill.CPS_play(['looking_for_freedom.mp3'],
+                            utterance='play on mothra')
+        self.audioservice.play.assert_called_once_with(
+            ['looking_for_freedom.mp3'], utterance='play on mothra')
+
+        self.audioservice.play.reset_mock()
+        # Assert that the utterance is injected
+        self.skill.CPS_play(['looking_for_freedom.mp3'])
+        self.audioservice.play.assert_called_once_with(
+            ['looking_for_freedom.mp3'], utterance='play on godzilla')
+
+    def test_stop(self):
+        """Test default reaction to stop command."""
+        self.audioservice.is_playing = False
+        self.assertFalse(self.skill.stop())
+
+        self.audioservice.is_playing = True
+        self.assertTrue(self.skill.stop())
+
+
+class TestCPSQuery(TestCase):
+    def setUp(self):
+        self.skill = CPSTest()
+        self.bus = mock.Mock(name='bus')
+        self.skill.bind(self.bus)
+        self.audioservice = mock.Mock(name='audioservice')
+        self.skill.audioservice = self.audioservice
+        self.query_phrase = self.bus.on.call_args_list[-2][0][1]
+
+    def test_handle_play_query_no_match(self):
+        """Test common play match when no match is found."""
+
+        # Check Not matching queries
+        self.skill.CPS_match_query_phrase.return_value = None
+        self.query_phrase(Message('play:query',
+                                  data={'phrase': 'Monster mash'}))
+
+        # Check that the skill replied that it was searching
+        extension = self.bus.emit.call_args_list[-2][0][0]
+        self.assertEqual(extension.data['phrase'], 'Monster mash')
+        self.assertEqual(extension.data['skill_id'], self.skill.skill_id)
+        self.assertEqual(extension.data['searching'], True)
+
+        # Assert that the skill reported that it couldn't find the phrase
+        response = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(response.data['phrase'], 'Monster mash')
+        self.assertEqual(response.data['skill_id'], self.skill.skill_id)
+        self.assertEqual(response.data['searching'], False)
+
+    def test_play_query_match(self):
+        """Test common play match when a match is found."""
+        phrase = 'Don\'t open until doomsday'
+        self.skill.CPS_match_query_phrase.return_value = (phrase,
+                                                          CPSMatchLevel.TITLE)
+        self.query_phrase(Message('play:query',
+                                  data={'phrase': phrase}))
+        # Assert that the skill reported the correct confidence
+        response = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(response.data['phrase'], phrase)
+        self.assertEqual(response.data['skill_id'], self.skill.skill_id)
+        self.assertAlmostEqual(response.data['conf'], 0.85)
+
+        # Partial phrase match
+        self.skill.CPS_match_query_phrase.return_value = ('until doomsday',
+                                                          CPSMatchLevel.TITLE)
+        self. query_phrase(Message('play:query',
+                                   data={'phrase': phrase}))
+        # Assert that the skill reported the correct confidence
+        response = self.bus.emit.call_args_list[-1][0][0]
+        self.assertEqual(response.data['phrase'], phrase)
+        self.assertEqual(response.data['skill_id'], self.skill.skill_id)
+        self.assertAlmostEqual(response.data['conf'], 0.825)
+
+
+class CPSTest(CommonPlaySkill):
+    """Simple skill for testing the CommonPlaySkill"""
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.CPS_match_query_phrase = mock.Mock(name='match_phrase')
+        self.CPS_start = mock.Mock(name='start_playback')
+        self.skill_id = 'CPSTest'
+
+    def CPS_match_query_phrase(self, phrase):
+        pass
+
+    def CPS_start(self, data):
+        pass