Repository: beetbox/beets

Issue:
parentwork: In tests, mock all MusicBrainz responses
I didn't notice this when we originally merged the parentwork plugin in #3279, but its tests rely on real communication with the MusicBrainz web service, i.e., they fail if there is no network connectivity. [This Travis job](https://travis-ci.org/beetbox/beets/jobs/558936634) is an example of a spurious failure caused by network interruptions.

We need to isolate these tests by mocking the MB queries so that no network traffic is every actually sent.

@dosoe, can you please look into this?


Discussion:
Hi! I can, but not now as I'm rather occupied these days, and when I will I will probably come back to you as I have no experience with this. If I get it well, the tests test with real Musicbrainz data and it should just request data that we would simulate to separte the network connectivity from the actual code, right?
Yeah! If you like, you can look around at other tests for MusicBrainz-related stuff that we have that also provides "mock" results from the MB API and asserts that the code processes them correctly.
Hi! I do have some free time now and I'm trying to get this done. I'm wondering about mocking MB-API outputs: I'm using ```musicbrainzngs.get_work_by_id``` in the core part of the plugin (the part that would need to be tested) and I don't quite get how to tell it to get the result from a mock. I had a look at ```test_mb.py```, ```test_discogs.py``` and ```test_mbsync.py``` and they all separate the part where they get the info from the part where they process it, while I don't see how I could do the same. 
How about mocking `musicbrainzngs.get_work_by_id` itself? That is, you'd replace that function with one that just returns a constant work information `dict`, perhaps collected by just capturing the output of a "real" call to the library. Would that work?
But when I run the functions and methods in ```parentwork```, how do I tell it to use this mock function instead of the real one? 
That's what the [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) library does for you. There are several ways to do this mocking—check out the docs for details. But the easiest one is probably the `@patch` decorator, like in this test that mocks `os.stat`:
https://github.com/beetbox/beets/blob/0c033bdaf10b78be04e6ebc8f998d9d3d5f70e3d/test/test_thumbnails.py#L50-L66
Ok I will try this out. Thanks! By the way, I made another PR (a draft so far) here: https://github.com/beetbox/beets/pull/3557
Here is a recent CI failure caused by these networked tests. It would be really great to get this stuff fixed. https://ci.appveyor.com/project/beetbox/beets/builds/33986042/job/gipja1xj6o6948vi
I tried to look into this. I don't quite understand how ```mock``` works, though. I want to use a function that I import from the plugin that uses a network call, is there a way to mock the network call (and not the function)? 
More in detail: 
I test the function ```direct_parent_id``` from the plugin, so I can't mock it. This function makes network calls, can I mock these even if the function is in a different library?
something like 
```
from beetsplug import parentwork
class ParentWorkTest(unittest.TestCase, TestHelper):
    def test_direct_parent_work(self):
        mb_workid = u'2e4a3668-458d-3b2a-8be2-0b08e0d8243a'
        self.assertEqual(u'f04b42df-7251-4d86-a5ee-67cfa49580d1',
                         parentwork.direct_parent_id(mb_workid)[0])
        self.assertEqual(u'45afb3b2-18ac-4187-bc72-beb1b1c194ba',
                         parentwork.work_parent_id(mb_workid)[0])
```
can I mock the network call made in ```parentwork.direct_parent_id``` without mocking ```parentwork.direct_parent_id``` itself?
You can mock any function, including those from other libraries. So the trick is basically to find the "lowest-level" function that wraps a network call that you can and produce responses from that.

I don't know how to mock *one* call to a function without mocking *all* the calls, although it may be possible. However, I think what you want to mock here might be `musicbrainzngs.get_work_by_id`. By providing direct responses to that function, you can more or less directly avoid needing to use actual MB servers.
Ok that wasn't clear to me. Yes, in this case ```musicbrainzngs.get_work_by_id``` is the one I want to mock, but I was uncertain whether if I mock it in the test it would also be mocked in the plugin when I call a function of the plugin that uses it. 
I snuck in this fix https://github.com/beetbox/beets/pull/3661/commits/25c6843c284ad7402b22467a18f743bbf58191e4 with https://github.com/beetbox/beets/pull/3661 that turned these tests into integration tests. Ideally, we also test our end of the code with unittests as well.

Resolving patch:
diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py
--- a/beetsplug/parentwork.py
+++ b/beetsplug/parentwork.py
@@ -96,7 +96,7 @@ def func(lib, opts, args):
                         item.try_write()
         command = ui.Subcommand(
             'parentwork',
-            help=u'fetche parent works, composers and dates')
+            help=u'fetch parent works, composers and dates')
 
         command.parser.add_option(
             u'-f', u'--force', dest='force',
@@ -129,6 +129,7 @@ def get_info(self, item, work_info):
         if 'artist-relation-list' in work_info['work']:
             for artist in work_info['work']['artist-relation-list']:
                 if artist['type'] == 'composer':
+                    composer_exists = True
                     parent_composer.append(artist['artist']['name'])
                     parent_composer_sort.append(artist['artist']['sort-name'])
                     if 'end' in artist.keys():


Test patch:
diff --git a/test/test_parentwork.py b/test/test_parentwork.py
--- a/test/test_parentwork.py
+++ b/test/test_parentwork.py
@@ -20,12 +20,52 @@
 import os
 import unittest
 from test.helper import TestHelper
+from mock import patch
 
 from beets.library import Item
 from beetsplug import parentwork
 
 
-class ParentWorkTest(unittest.TestCase, TestHelper):
+work = {'work': {'id': '1',
+                 'title': 'work',
+                 'work-relation-list': [{'type': 'parts',
+                                         'direction': 'backward',
+                                         'work': {'id': '2'}}],
+                 'artist-relation-list': [{'type': 'composer',
+                                           'artist': {'name':
+                                                      'random composer',
+                                                      'sort-name':
+                                                      'composer, random'}}]}}
+dp_work = {'work': {'id': '2',
+                    'title': 'directparentwork',
+                    'work-relation-list': [{'type': 'parts',
+                                            'direction': 'backward',
+                                            'work': {'id': '3'}}],
+                    'artist-relation-list': [{'type': 'composer',
+                                              'artist': {'name':
+                                                         'random composer',
+                                                         'sort-name':
+                                                         'composer, random'
+                                                         }}]}}
+p_work = {'work': {'id': '3',
+                   'title': 'parentwork',
+                   'artist-relation-list': [{'type': 'composer',
+                                             'artist': {'name':
+                                                        'random composer',
+                                                        'sort-name':
+                                                        'composer, random'}}]}}
+
+
+def mock_workid_response(mbid, includes):
+    if mbid == '1':
+        return work
+    elif mbid == '2':
+        return dp_work
+    elif mbid == '3':
+        return p_work
+
+
+class ParentWorkIntegrationTest(unittest.TestCase, TestHelper):
     def setUp(self):
         """Set up configuration"""
         self.setup_beets()
@@ -35,12 +75,15 @@ def tearDown(self):
         self.unload_plugins()
         self.teardown_beets()
 
+    # test how it works with real musicbrainz data
     @unittest.skipUnless(
         os.environ.get('INTEGRATION_TEST', '0') == '1',
         'integration testing not enabled')
-    def test_normal_case(self):
+    def test_normal_case_real(self):
         item = Item(path='/file',
-                    mb_workid=u'e27bda6e-531e-36d3-9cd7-b8ebc18e8c53')
+                    mb_workid=u'e27bda6e-531e-36d3-9cd7-b8ebc18e8c53',
+                    parentwork_workid_current=u'e27bda6e-531e-36d3-9cd7-\
+                    b8ebc18e8c53')
         item.add(self.lib)
 
         self.run_command('parentwork')
@@ -52,11 +95,13 @@ def test_normal_case(self):
     @unittest.skipUnless(
         os.environ.get('INTEGRATION_TEST', '0') == '1',
         'integration testing not enabled')
-    def test_force(self):
+    def test_force_real(self):
         self.config['parentwork']['force'] = True
         item = Item(path='/file',
                     mb_workid=u'e27bda6e-531e-36d3-9cd7-b8ebc18e8c53',
-                    mb_parentworkid=u'XXX')
+                    mb_parentworkid=u'XXX',
+                    parentwork_workid_current=u'e27bda6e-531e-36d3-9cd7-\
+                    b8ebc18e8c53', parentwork='whatever')
         item.add(self.lib)
 
         self.run_command('parentwork')
@@ -68,10 +113,12 @@ def test_force(self):
     @unittest.skipUnless(
         os.environ.get('INTEGRATION_TEST', '0') == '1',
         'integration testing not enabled')
-    def test_no_force(self):
-        self.config['parentwork']['force'] = True
+    def test_no_force_real(self):
+        self.config['parentwork']['force'] = False
         item = Item(path='/file', mb_workid=u'e27bda6e-531e-36d3-9cd7-\
-                    b8ebc18e8c53', mb_parentworkid=u'XXX')
+                    b8ebc18e8c53', mb_parentworkid=u'XXX',
+                    parentwork_workid_current=u'e27bda6e-531e-36d3-9cd7-\
+                    b8ebc18e8c53', parentwork='whatever')
         item.add(self.lib)
 
         self.run_command('parentwork')
@@ -85,7 +132,7 @@ def test_no_force(self):
     @unittest.skipUnless(
         os.environ.get('INTEGRATION_TEST', '0') == '1',
         'integration testing not enabled')
-    def test_direct_parent_work(self):
+    def test_direct_parent_work_real(self):
         mb_workid = u'2e4a3668-458d-3b2a-8be2-0b08e0d8243a'
         self.assertEqual(u'f04b42df-7251-4d86-a5ee-67cfa49580d1',
                          parentwork.direct_parent_id(mb_workid)[0])
@@ -93,6 +140,56 @@ def test_direct_parent_work(self):
                          parentwork.work_parent_id(mb_workid)[0])
 
 
+class ParentWorkTest(unittest.TestCase, TestHelper):
+    def setUp(self):
+        """Set up configuration"""
+        self.setup_beets()
+        self.load_plugins('parentwork')
+        self.patcher = patch('musicbrainzngs.get_work_by_id',
+                             side_effect=mock_workid_response)
+        self.patcher.start()
+
+    def tearDown(self):
+        self.unload_plugins()
+        self.teardown_beets()
+        self.patcher.stop()
+
+    def test_normal_case(self):
+        item = Item(path='/file', mb_workid='1', parentwork_workid_current='1')
+        item.add(self.lib)
+
+        self.run_command('parentwork')
+
+        item.load()
+        self.assertEqual(item['mb_parentworkid'], '3')
+
+    def test_force(self):
+        self.config['parentwork']['force'] = True
+        item = Item(path='/file', mb_workid='1', mb_parentworkid=u'XXX',
+                    parentwork_workid_current='1', parentwork='parentwork')
+        item.add(self.lib)
+
+        self.run_command('parentwork')
+
+        item.load()
+        self.assertEqual(item['mb_parentworkid'], '3')
+
+    def test_no_force(self):
+        self.config['parentwork']['force'] = False
+        item = Item(path='/file', mb_workid='1', mb_parentworkid=u'XXX',
+                    parentwork_workid_current='1', parentwork='parentwork')
+        item.add(self.lib)
+
+        self.run_command('parentwork')
+
+        item.load()
+        self.assertEqual(item['mb_parentworkid'], u'XXX')
+
+    def test_direct_parent_work(self):
+        self.assertEqual('2', parentwork.direct_parent_id('1')[0])
+        self.assertEqual('3', parentwork.work_parent_id('1')[0])
+
+
 def suite():
     return unittest.TestLoader().loadTestsFromName(__name__)