DEV Community

Hyunjin Shin (Jin)
Hyunjin Shin (Jin)

Posted on

OSD600 - lab 07

Link to Repo
Link to commit

Description

This post is about lab 07 for OSD600 course at Seneca College. This week we learned how to test our project. We learned about unit test, e2e(end to end), and integration test.

Progress

  • First, I needed to decide which test framewokr I am going to use. I asked chatGPT which one is most popular and also googled it and read some people's opinion. Then, I decided to use pytest framework.

  • Second, I started writing test code with each class that is called on top of the function call stacks. I chose the easist one, which is load_config.py. It only contained one function.

import os
import toml
def load_config():
    # Load config file from the project's root directory
    config_path = os.path.expanduser("~/.codemage-config.toml")  # Adjust path to project root
    if os.path.exists(config_path):
        config = toml.load(config_path)
    else:
        config = {}

    return config
Enter fullscreen mode Exit fullscreen mode

This function opens .toml file, and read key-value pairs. I had to mock the config file and its content.

class TestLoadConfig:
    @patch("os.path.exists")
    @patch("toml.load")
    def test_load_config_file_exists(self, mock_toml_load, mock_os_exists):
        # Simulate that the file exists
        mock_os_exists.return_value = True

        # Simulate the contents of the file
        mock_toml_load.return_value = {
            "language": "Python",
            "OPENROUTER_API_KEY": "mock_openrouter_api_key",
        }

        # Call load_config function
        config = load_config()

        # Assertions
        mock_os_exists.assert_called_once_with(os.path.expanduser("~/.codemage-config.toml"))
        mock_toml_load.assert_called_once_with(os.path.expanduser("~/.codemage-config.toml"))
        assert config == {"language": "Python", "OPENROUTER_API_KEY": "mock_openrouter_api_key"}
Enter fullscreen mode Exit fullscreen mode

I used @patch to mock the file, file status(exists or not), and the content. Then I checked if the file is called with the path and if file content is correct.

  • Third, I tested Api class. Since this is also independent and called on the top of the function stack.

class Api:
    def __init__(self, model, config):
        self.supported_model = ["groq", "openrouter"]
        self.model = model if model is not None else "openrouter"

        if self.model not in self.supported_model:
            sys.exit(f"{self.model} is not suppored. Model Supported: {self.supported_model}")

        # default api_url and api_model
        self.api_url = "https://openrouter.ai/api/v1"
        self.api_model = "sao10k/l3-euryale-70b"
        self.api_key = os.getenv("OPENROUTER_API_KEY") or config.get("OPENROUTER_API_KEY")

        # api_url and api_model when the provider is groq
        if self.model == "groq":
            self.api_url = "https://api.groq.com/openai/v1"
            self.api_model = "llama3-8b-8192"
            self.api_key = os.getenv("GROQ_API_KEY") or config.get("GROQ_API_KEY")

    def call_api(self, target_lang, code, stream_flag=False):
        client = OpenAI(
            base_url=self.api_url,
            api_key=self.api_key,
        )

        completion = client.chat.completions.create(
            extra_headers={},
            model=self.api_model,
            messages=[
                {
                    "role": "system",
                    "content": "only display the code without any explanation",
                },
                {
                    "role": "user",
                    "content": f"translate this to {target_lang} language: {code}",
                },
            ],
            stream=stream_flag,
        )

        return completion
Enter fullscreen mode Exit fullscreen mode

I tested the constructor first and then I tested the method call_api. Testing constructor was not difficult. I just needed to make a mock_config, and mock_args. Testing the method was pretty tough. I had to mock the response object.

class TestApiWithEnv:
    @pytest.fixture
    def mock_config(self):
        return {
            "OPENROUTER_API_KEY": "fake_openrouter_api_key_from_toml",
            "GROQ_API_KEY": "fake_groq_api_key",
        }

    # Mock OpenAI response
    def mock_api_call(self, *args, **kwargs):
        # Return a mock response object with a `choices` attribute
        mock_response = MagicMock()
        mock_response.choices = [{"message": {"content": "Translated code"}}]
        return mock_response

    # Test when model is supported
    @patch.dict(
        os.environ,
        {
            "OPENROUTER_API_KEY": "fake_api_key_from_env",
            "GROQ_API_KEY": "fake_groq_api_key_from_env",
        },
    )
    @patch("code_mage.api.OpenAI")
    def test_api_with_openrouter(self, mock_openai_class):
        # Mock the OpenAI client and its methods
        mock_client = MagicMock()

        # When the code create OpenAI class inside call_api(), it's replaced by mock_openai_class
        mock_openai_class.return_value = mock_client
        mock_client.chat.completions.create = MagicMock(return_value=self.mock_api_call())

        # Create an instance of the Api with a supported model
        api = Api(model="openrouter", config={})

        # Simulate an API call
        response = api.call_api(target_lang="python", code="some_code")

        # Check if the API call was made with the expected arguments
        mock_client.chat.completions.create.assert_called_once_with(
            extra_headers={},
            model="sao10k/l3-euryale-70b",
            messages=[
                {"role": "system", "content": "only display the code without any explanation"},
                {"role": "user", "content": "translate this to python language: some_code"},
            ],
            stream=False,
        )

        # Assert the response content
        assert response.choices[0]["message"]["content"] == "Translated code"
Enter fullscreen mode Exit fullscreen mode

Fortunately, there is a way to mock api call provided. I used

@patch("code_mage.api.OpenAI")
Enter fullscreen mode Exit fullscreen mode

This is one of my Test codes for Api Class.

class TestApiWithEnv:
    @pytest.fixture
    def mock_config(self):
        return {
            "OPENROUTER_API_KEY": "fake_openrouter_api_key_from_toml",
            "GROQ_API_KEY": "fake_groq_api_key",
        }

    # Mock OpenAI response
    def mock_api_call(self, *args, **kwargs):
        # Return a mock response object with a `choices` attribute
        mock_response = MagicMock()
        mock_response.choices = [{"message": {"content": "Translated code"}}]
        return mock_response

    # Test when model is supported
    @patch.dict(
        os.environ,
        {
            "OPENROUTER_API_KEY": "fake_api_key_from_env",
            "GROQ_API_KEY": "fake_groq_api_key_from_env",
        },
    )
    @patch("code_mage.api.OpenAI")
    def test_api_with_openrouter(self, mock_openai_class):
        # Mock the OpenAI client and its methods
        mock_client = MagicMock()

        # When the code create OpenAI class inside call_api(), it's replaced by mock_openai_class
        mock_openai_class.return_value = mock_client
        mock_client.chat.completions.create = MagicMock(return_value=self.mock_api_call())

        # Create an instance of the Api with a supported model
        api = Api(model="openrouter", config={})

        # Simulate an API call
        response = api.call_api(target_lang="python", code="some_code")

        # Check if the API call was made with the expected arguments
        mock_client.chat.completions.create.assert_called_once_with(
            extra_headers={},
            model="sao10k/l3-euryale-70b",
            messages=[
                {"role": "system", "content": "only display the code without any explanation"},
                {"role": "user", "content": "translate this to python language: some_code"},
            ],
            stream=False,
        )

        # Assert the response content
        assert response.choices[0]["message"]["content"] == "Translated code"
Enter fullscreen mode Exit fullscreen mode

I used ChatGPT; it gave me good hints, but didn't work well, I had to make some changes and it worked. If I am being honest, I am still confused how it works, but it looks like it tests the code as it should. I think I need to study more to fully understand and write test code.

  • Last, the most difficult part was testing Translator class. It calls other functions that requires mock environments such as openeing a file from file system and calling api.

This is my test code for translate() function in Translator class:

class TestTranslator:
    @pytest.fixture
    def mock_args(self):
        """Fixture for simulating command-line arguments."""
        return Mock(language=None, model=None, stream=False, token_usage=False, output=None)

    def mock_api_call(self, *args, **kwargs):
        mock_response = MagicMock()
        mock_response.choices = [{"message": {"content": "Translated code"}}]
        return mock_response

    @patch("builtins.open", new_callable=MagicMock)  # Mock open
    @patch("code_mage.translator.Api")  # Mock Api class
    @patch.object(Translator, "_Translator__get_output_filename", return_value="translated_test.py")
    def test_translate(self, mock_get_output_filename, mock_api, mock_open, mock_args):
        mock_config = {"api_key": "fake_api_key"}

        # Initialize Translator instance
        translator = Translator(mock_args, mock_config)

        mock_file = MagicMock()
        mock_open.return_value = mock_file

        # Mock the Api's call_api method
        mock_api_instance = MagicMock()
        mock_api.return_value = mock_api_instance
        mock_api_instance.chat.completions.create = MagicMock(return_value=self.mock_api_call())

        translator.translate("./example/test.js")

        assert mock_api_instance.chat.completions.create.return_value.choices == [
            {"message": {"content": "Translated code"}}
        ]

        mock_open.assert_any_call("./example/test.js", "r")
        mock_open.assert_any_call("translated_test.py", "w")
Enter fullscreen mode Exit fullscreen mode

What was challenging was that I didn't understand how it knows which mock is for api_call() and which mock is for open() when I made two Mock objects. I tested it by changing the order of arguments, and the name of arguments. I think it maps @patch result to the arguments by order and name. I don't understand how exactly it works, but I learned that the order and name matters and I should make sure that they followes the order in which @patch is called and the name.

Conclusion

At first, I thought that it is going to be easy. However, it was really tough, especially for mocking object, .env, file open(), http request, and http response. It also was kind of fun. From this lab, I learned how to test code thoroughly and how important it is to modulize the code so that I can test the code in a isolated environment. It's not perfect but at least it guarantees a certain level of robustness.

Top comments (0)