0

I am trying to mock the secrets manager client. Earlier the variables weren't in the class so I was able to mock the client directly using a patch like below:

@patch('my_repo.rc.client')

and now since I am using an instance method, I need to mock the instance method.

rc.py

import boto3
import json
from services.provisioner_logger import get_provisioner_logger
from services.exceptions import UnableToRetrieveDetails


class MyRepo(object):
    def __init__(self, region):
        self.client = self.__get_client(region)

    def id_lookup(self, category):
        logger = get_provisioner_logger()
        try:
            response = self.client.get_secret_value(SecretId=category)
            result = json.loads(response['SecretString'])
            logger.info("Got value for secret %s.", category)
            return result
        except Exception as e:
            logger.error("unable to retrieve secret details due to ", str(e))
            raise Exception("unable to retrieve secret details due to ", str(e))

    def __get_client(self, region):
        return boto3.session.Session().client(
            service_name='secretsmanager',
            region_name=region
        )

test_secrt.py

from unittest import TestCase
from unittest.mock import patch, MagicMock
from my_repo.rc import MyRepo
import my_repo


class TestSecretManagerMethod(TestCase):
    def test_get_secret_value(self):
        with patch.object(my_repo.rc.MyRepo, "id_lookup") as fake_bar_mock:
            fake_bar_mock.get_secret_value.return_value = {
                "SecretString": '{"secret": "gotsomecreds"}',
            }
            actual = MyRepo("eu-west-1").id_lookup("any-name")

            self.assertEqual(actual, {"secret": "gotsomecreds"})

Now, I tried a SO post to implement the same but the end result isn't matching. It gives results like below:

self.assertEqual(actual, {"secret": "gotsomecreds"})
AssertionError: <MagicMock name='id_lookup()' id='4589498032'> != {'secret': 'gotsomecreds'}  

I think I am close but unable to find out what exactly am I missing here.

RushHour
  • 494
  • 6
  • 25
  • You have mocked the find_by_id function, which you are trying to test. But you then set `get_secret_value` which is a method of the client. Try to mock `__get_client` instead? – doctorlove Dec 22 '22 at 10:02
  • I tried that too. It results in an error. It is a private method. How can I mock that? – RushHour Dec 22 '22 at 10:04
  • Fair. You might need to mock the boto3.session and other methods - see here https://stackoverflow.com/questions/67502338/how-to-mock-the-boto3-client-session-requests-for-secretsmanager-to-either-retur – doctorlove Dec 22 '22 at 10:10
  • I am actually struggling to implement it. Can you help with one example so that I will understand better – RushHour Dec 22 '22 at 10:19

2 Answers2

1

OK, we want a Mock, we don't need a magic mock. In fact, we want 3.

First, the session

mock_session_object = Mock()

Then the client,

mock_client = Mock()

This mock client will return you response:

mock_client.get_secret_value.return_value = {
            "SecretString": '{"secret": "gotsomecreds"}',
        }

The session client will return this:

mock_session_object.client.return_value = mock_client

OK. That was a lot, but we have clients inside sessions. Pulling it togther, we have

from unittest import TestCase
from unittest.mock import patch, Mock
from credentials_repo.retrieve_credentials import CredentialsRepository
import credentials_repo


class TestSecretManagerMethod(TestCase):
    @patch("boto3.session.Session")
    def test_get_secret_value(self, mock_session_class):
        mock_session_object = Mock()
        mock_client = Mock()
        mock_client.get_secret_value.return_value = {
                "SecretString": '{"secret": "gotsomecreds"}',
            }
        mock_session_object.client.return_value = mock_client
        mock_session_class.return_value = mock_session_object
        actual = CredentialsRepository("eu-west-1").find_by_id("db-creds")

        self.assertEqual(actual, {"secret": "gotsomecreds"})

(The @path at the top is the same as a with inside, right?)

doctorlove
  • 18,872
  • 2
  • 46
  • 62
  • Thanks a lot for your help. Yeah `path` and `with` are just the ways of implementing. Also, I didn't get your two lines `mock_session_object.client.return_value = mock_client mock_session_class.return_value = mock_session_object` . Why can't we just return `mock_session_object` or `mock_session_class`? – RushHour Dec 22 '22 at 11:08
  • Both mocks need the `return_value`. You could roll them all together, but it might be clearer having each separately. – doctorlove Dec 22 '22 at 11:20
  • Cool and mock_session_class has both mocks right the `mock_session_object` and `mock_client`. – RushHour Dec 22 '22 at 11:35
  • Could you help me with this one https://stackoverflow.com/questions/74940951/assert-called-with-is-not-called-actually-in-python-unittest ? – RushHour Dec 28 '22 at 18:13
0

If you were to rename the method __get_client to a method you could override such as _connect you could simply patch your class:

import boto3
import json
from services.provisioner_logger import get_provisioner_logger
from services.exceptions import UnableToRetrieveDetails

SECRET_STRING = '{"secret": "gotsomecreds"}'

class MockSecretsClient:
    """
    Mock class for the boto3 client.
    """

    def get_secret_value(self, SecretId):
        """
        Mock method for the get_secret_value method.
        """
        return SECRET_STRING


class TestSecretManagerMethod(TestCase):
    def test_get_secret_value(self):
        with patch.object(my_repo.rc.MyRepo, "_connect") as fake_bar_mock:
            fake_bar_mock._connect.return_value = MockSecretsClient()
            actual = MyRepo("eu-west-1").id_lookup("any-name")

            self.assertEqual(actual, {"secret": "gotsomecreds"})

This method also allows you to override any other functionality you might need to in the mock client.

I did something very similar to this if you want more examples here: https://github.com/gdoermann/supersecret/blob/main/tests/test_manager.py

hazmat
  • 640
  • 5
  • 12