DEV Community

fadingNA
fadingNA

Posted on • Edited on

UnitTesting - Chatminal

Overview

Testing is a crucial step in the software development lifecycle that ensures your code works as expected and adheres to the specified logic. Proper testing not only validates the functionality of individual components but also guards against potential regressions when changes are introduced. Among various testing approaches, unit testing stands out as one of the most effective strategies for validating individual functions and methods in isolation.

In this blog, we’ll explore the significance of unit testing in Python using the unittest framework, with a focus on leveraging mocking techniques to isolate code, simulate dependencies, and verify behavior. Our practical example involves testing a custom object and mocking API requests from Langchain.

Unit Testing

  • Detect Bug
  • Improved Code Quality
  • Checked Code flow
  • Ensure development Cycles

Unit testing verifies that each functions of application working properly. The focus is on every part of an application that I have build call Chatminal, and I will implement 2 significant testing model, Chat minal object and mock api request from Langchain

Methodology

In Python, unit testing often involves object-oriented instances, though this approach isn’t strictly necessary. However, adopting object-oriented testing practices offers cleaner and more organized tests. Consider the following test structure using unittest and unittest.mock for mocking dependencies:

@patch("sys.exit")
@patch("app.completion_tool.logger")
class TestMinal(unittest.TestCase):
Enter fullscreen mode Exit fullscreen mode

Here, the two decorators @patch("sys.exit") and @patch("app.completion_tool.logger") mock the sys.exit call used to terminate the application and a logger used to debug application behavior.

First method on that TestMinal Object
Consider a method that tests argument parsing for a file, because our application depend from file input to give context to LLM.

    def test_only_file_argument(self, mock_logger, mock_exit):
        with patch("sys.argv", ["completion_tool.py", "test_file.txt"]):
            with patch("os.path.exists", return_value=True):
                self.minal.parse_arguments()
                self.assertEqual(self.minal.input_text, "Default input text")
                self.assertEqual(self.minal.temperature, 0.5)
                self.assertEqual(self.minal.max_tokens, 100)
                self.assertEqual(self.minal.model, "gpt-4")
                mock_exit.assert_not_called()
Enter fullscreen mode Exit fullscreen mode

This test verifies that when a valid file argument is provided, the default values for other parameters are correctly set, and sys.exit is not called.

This test suite demonstrates key unit testing techniques, including:

  • Patching System Calls, mocking sys.exit and loggers for controlled test behaviour.
  • Mocking Dependencies, simulating external responses to isolate functionality.
  • Argument Parsing Validation, ensuring command-line arguments are processed correctly.

Mock API Request

First define private variable to mock response from LangChain Completion

self.mock_response.response_metadata = {
"token_usage": {
"completion_tokens": 86,
"prompt_tokens": 48,
"total_tokens": 134,
},
"model_name": "gpt-4-0613",
"finish_reason": "stop",
}
self.mock_response.id = "mocked-id-12345"
self.mock_response.usage_metadata = {
"input_tokens": 48,
"output_tokens": 86,
"total_tokens": 134,
}

    def test_generate_completion(self, mock_logger, mock_exit):
        with patch("app.completion_tool.LangChainOpenAI") as MockLangChainOpenAI:
            mock_llm_instance = MockLangChainOpenAI.return_value
            mock_chain_llm = self.mock_response.__or__.return_value
            mock_chain_llm.invoke.return_value = self.mock_response

            # Patch get_prompt_template and get_response to return mocks
            with patch.object(
                self.minal, "get_prompt_template", return_value=self.mock_response
            ):
                with patch.object(
                    self.minal, "get_response", return_value=mock_llm_instance
                ):
                    # using patch to mock the standard output
                    # and capture the printed output
                    # because our project print _reponse.content + '\n'
                    # to the standard output and we have to capture and mock it
                    with patch("sys.stdout", new_callable=io.StringIO) as fake_out:
                        self.minal.generate_completion()
                        printed_output = fake_out.getvalue()

                    expected_output = self.mock_response.content + "\n"
                    self.assertEqual(printed_output, expected_output)
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • Effective Mocking, this approach ensures that your tests do not depend on live API calls, making them faster, more predictable, and less error-prone.
  • Output Verification, by capturing sys.stdout, you can verify the behavior of functions that print output, which is particularly useful for command-line tools.
  • Isolated Testing, by controlling all external dependencies with mocks, you can test the logic and behavior of your code in isolation.

This method ensures that your code behaves correctly when interacting with external services and allows for comprehensive testing without actual network calls.

Conclusion

Unit testing is an indispensable practice for ensuring the reliability and maintainability of your Python applications. By mastering the use of unittest.mock, you can effectively isolate your code, simulate external dependencies, and verify both functional behavior and logging output. Remember to:

  • 1. Mock Correctly: Always patch the object where it’s used, not where it’s defined.
  • 2. Maintain Correct Argument Order: Especially when using multiple @patch decorators.
  • 3. Prefer Mocking Over Capturing stdout: Mocking functions like print leads to cleaner and more reliable tests.
  • 4. Leverage assertLogs: For testing logging output without complex mocking.
  • 5. Refactor for Testability: Design your code in a way that facilitates easy and effective testing.

By following these guidelines and learning from common pitfalls, you’ll enhance your testing strategy and build more resilient Python applications.

Top comments (0)