Bài 13: Testing - unittest & pytest Basics

Mục Tiêu Bài Học

Sau khi hoàn thành bài này, bạn sẽ:

  • ✅ Hiểu tầm quan trọng của testing
  • ✅ Sử dụng unittest module
  • ✅ Làm việc với pytest framework
  • ✅ Viết test cases
  • ✅ Sử dụng assertions
  • ✅ Organize tests properly

Why Testing?

# Code without tests - riskydef calculate_discount(price, discount):    return price * (1 - discount) # What if discount > 1?# What if price is negative?# What if discount is 0? # With tests - confidencedef test_calculate_discount():    assert calculate_discount(100, 0.1) == 90    assert calculate_discount(100, 0) == 100    assert calculate_discount(100, 1) == 0 # Benefits:# 1. Catch bugs early# 2. Prevent regressions# 3. Document behavior# 4. Enable refactoring# 5. Improve design

unittest Module

unittest là built-in testing framework trong Python.

Basic Test Case

import unittest # Code to testdef add(a, b):    return a + b def subtract(a, b):    return a - b # Test classclass TestMathFunctions(unittest.TestCase):    def test_add(self):        """Test add function."""        self.assertEqual(add(2, 3), 5)        self.assertEqual(add(-1, 1), 0)        self.assertEqual(add(0, 0), 0)        def test_subtract(self):        """Test subtract function."""        self.assertEqual(subtract(5, 3), 2)        self.assertEqual(subtract(0, 5), -5)        self.assertEqual(subtract(10, 10), 0) # Run testsif __name__ == '__main__':    unittest.main() # Output:# ..# ----------------------------------------------------------------------# Ran 2 tests in 0.001s# OK

Assertion Methods

import unittest class TestAssertions(unittest.TestCase):    def test_equality(self):        """Test equality assertions."""        self.assertEqual(1 + 1, 2)        self.assertNotEqual(1, 2)        def test_boolean(self):        """Test boolean assertions."""        self.assertTrue(True)        self.assertFalse(False)        def test_none(self):        """Test None assertions."""        self.assertIsNone(None)        self.assertIsNotNone("something")        def test_membership(self):        """Test membership assertions."""        self.assertIn('a', 'abc')        self.assertNotIn('x', 'abc')        def test_type(self):        """Test type assertions."""        self.assertIsInstance(5, int)        self.assertNotIsInstance("5", int)        def test_greater_less(self):        """Test comparison assertions."""        self.assertGreater(5, 3)        self.assertLess(3, 5)        self.assertGreaterEqual(5, 5)        self.assertLessEqual(3, 5)        def test_almost_equal(self):        """Test floating point assertions."""        self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)        def test_exceptions(self):        """Test exception raising."""        with self.assertRaises(ValueError):            int("not a number")                with self.assertRaises(ZeroDivisionError):            1 / 0 if __name__ == '__main__':    unittest.main()

setUp and tearDown

import unittest class TestWithSetup(unittest.TestCase):    def setUp(self):        """Run before each test."""        print("Setting up test")        self.data = [1, 2, 3, 4, 5]        def tearDown(self):        """Run after each test."""        print("Tearing down test")        self.data = None        def test_sum(self):        """Test sum of data."""        self.assertEqual(sum(self.data), 15)        def test_length(self):        """Test length of data."""        self.assertEqual(len(self.data), 5)        @classmethod    def setUpClass(cls):        """Run once before all tests in class."""        print("Setup class")        @classmethod    def tearDownClass(cls):        """Run once after all tests in class."""        print("Teardown class") if __name__ == '__main__':    unittest.main()

pytest Framework

pytest là popular third-party testing framework, simpler và more powerful than unittest.

Installation

pip install pytest

Basic Test

# test_math.pydef add(a, b):    return a + b def subtract(a, b):    return a - b # Tests - simple functions starting with test_def test_add():    assert add(2, 3) == 5    assert add(-1, 1) == 0    assert add(0, 0) == 0 def test_subtract():    assert subtract(5, 3) == 2    assert subtract(0, 5) == -5    assert subtract(10, 10) == 0 # Run: pytest test_math.py

pytest Assertions

# test_assertions.pydef test_equality():    """Test equality."""    assert 1 + 1 == 2    assert "hello" == "hello"    assert [1, 2, 3] == [1, 2, 3] def test_membership():    """Test membership."""    assert 'a' in 'abc'    assert 'x' not in 'abc'    assert 1 in [1, 2, 3] def test_type():    """Test types."""    assert isinstance(5, int)    assert isinstance("hello", str)    assert type(5) == int def test_comparison():    """Test comparisons."""    assert 5 > 3    assert 3 < 5    assert 5 >= 5    assert 5 <= 5 def test_boolean():    """Test boolean."""    assert True    assert not False    assert 1  # Truthy    assert not 0  # Falsy def test_none():    """Test None."""    value = None    assert value is None        value = "something"    assert value is not None # Run: pytest test_assertions.py -v

Testing Exceptions

# test_exceptions.pyimport pytest def divide(a, b):    if b == 0:        raise ValueError("Cannot divide by zero")    return a / b def test_divide_success():    """Test successful division."""    assert divide(10, 2) == 5    assert divide(9, 3) == 3 def test_divide_by_zero():    """Test division by zero raises ValueError."""    with pytest.raises(ValueError) as exc_info:        divide(10, 0)        assert "Cannot divide by zero" in str(exc_info.value) def test_divide_by_zero_simple():    """Simpler syntax."""    with pytest.raises(ValueError):        divide(10, 0) # Test multiple exceptionsdef test_int_conversion():    """Test invalid int conversion."""    with pytest.raises(ValueError):        int("not a number")

Organizing Tests

Test File Structure

# Project structure# project/#   src/#     calculator.py#   tests/#     test_calculator.py#     test_advanced.py # src/calculator.pyclass Calculator:    def add(self, a, b):        return a + b        def subtract(self, a, b):        return a - b        def multiply(self, a, b):        return a * b        def divide(self, a, b):        if b == 0:            raise ValueError("Cannot divide by zero")        return a / b # tests/test_calculator.pyimport pytestfrom src.calculator import Calculator class TestCalculator:    def setup_method(self):        """Setup for each test method."""        self.calc = Calculator()        def test_add(self):        assert self.calc.add(2, 3) == 5        assert self.calc.add(-1, 1) == 0        def test_subtract(self):        assert self.calc.subtract(5, 3) == 2        assert self.calc.subtract(0, 5) == -5        def test_multiply(self):        assert self.calc.multiply(3, 4) == 12        assert self.calc.multiply(0, 5) == 0        def test_divide(self):        assert self.calc.divide(10, 2) == 5        assert self.calc.divide(9, 3) == 3        def test_divide_by_zero(self):        with pytest.raises(ValueError):            self.calc.divide(10, 0) # Run: pytest tests/

Parametrized Tests

# test_parametrized.pyimport pytest def add(a, b):    return a + b @pytest.mark.parametrize("a,b,expected", [    (2, 3, 5),    (0, 0, 0),    (-1, 1, 0),    (10, -5, 5),])def test_add_parametrized(a, b, expected):    """Test add with multiple inputs."""    assert add(a, b) == expected # More complex parametrization@pytest.mark.parametrize("input,expected", [    ("hello", "HELLO"),    ("World", "WORLD"),    ("", ""),    ("123", "123"),])def test_uppercase(input, expected):    """Test string uppercase."""    assert input.upper() == expected # Multiple parameters@pytest.mark.parametrize("x", [1, 2, 3])@pytest.mark.parametrize("y", [4, 5, 6])def test_multiply(x, y):    """Test multiplication combinations."""    assert x * y == y * x  # Commutative # Run: pytest test_parametrized.py -v

Real-world Examples

1. Testing User Model

# models.pyclass User:    def __init__(self, username, email, age):        self.username = username        self.email = email        self.age = age        def is_adult(self):        return self.age >= 18        def validate(self):        errors = []                if not self.username:            errors.append("Username is required")                if len(self.username) < 3:            errors.append("Username must be at least 3 characters")                if "@" not in self.email:            errors.append("Invalid email")                if self.age < 0:            errors.append("Age cannot be negative")                return errors # test_models.pyimport pytestfrom models import User class TestUser:    def test_user_creation(self):        """Test user creation."""        user = User("alice", "[email protected]", 25)        assert user.username == "alice"        assert user.email == "[email protected]"        assert user.age == 25        def test_is_adult(self):        """Test is_adult method."""        adult = User("bob", "[email protected]", 18)        assert adult.is_adult() is True                minor = User("charlie", "[email protected]", 17)        assert minor.is_adult() is False        def test_validate_valid_user(self):        """Test validation of valid user."""        user = User("alice", "[email protected]", 25)        errors = user.validate()        assert len(errors) == 0        def test_validate_short_username(self):        """Test validation with short username."""        user = User("ab", "[email protected]", 25)        errors = user.validate()        assert "Username must be at least 3 characters" in errors        def test_validate_invalid_email(self):        """Test validation with invalid email."""        user = User("alice", "invalid-email", 25)        errors = user.validate()        assert "Invalid email" in errors        def test_validate_negative_age(self):        """Test validation with negative age."""        user = User("alice", "[email protected]", -5)        errors = user.validate()        assert "Age cannot be negative" in errors

2. Testing API Client

# api_client.pyimport requests class APIClient:    def __init__(self, base_url):        self.base_url = base_url        def get_user(self, user_id):        response = requests.get(f"{self.base_url}/users/{user_id}")        response.raise_for_status()        return response.json()        def create_user(self, data):        response = requests.post(f"{self.base_url}/users", json=data)        response.raise_for_status()        return response.json() # test_api_client.pyimport pytestfrom api_client import APIClient class TestAPIClient:    def test_get_user_success(self):        """Test successful user fetch."""        client = APIClient("https://api.example.com")        # Would need mocking for real test        # user = client.get_user(1)        # assert user['id'] == 1        def test_create_user_success(self):        """Test successful user creation."""        client = APIClient("https://api.example.com")        data = {"name": "Alice", "email": "[email protected]"}        # Would need mocking for real test        # user = client.create_user(data)        # assert user['name'] == "Alice"

3. Testing String Utils

# string_utils.pydef slugify(text):    """Convert text to URL-friendly slug."""    import re        text = text.lower()    text = re.sub(r'[^\w\s-]', '', text)    text = re.sub(r'[\s_]+', '-', text)    text = re.sub(r'^-+|-+$', '', text)        return text def truncate(text, length, suffix="..."):    """Truncate text to specified length."""    if len(text) <= length:        return text        return text[:length - len(suffix)] + suffix # test_string_utils.pyimport pytestfrom string_utils import slugify, truncate class TestSlugify:    @pytest.mark.parametrize("input,expected", [        ("Hello World", "hello-world"),        ("Python  Programming", "python-programming"),        ("[email protected]", "testexamplecom"),        ("  Spaces  ", "spaces"),        ("Multiple---Dashes", "multiple-dashes"),    ])    def test_slugify(self, input, expected):        assert slugify(input) == expected class TestTruncate:    def test_truncate_short_text(self):        """Test truncate with text shorter than limit."""        result = truncate("Hello", 10)        assert result == "Hello"        def test_truncate_long_text(self):        """Test truncate with text longer than limit."""        result = truncate("Hello World", 8)        assert result == "Hello..."        def test_truncate_custom_suffix(self):        """Test truncate with custom suffix."""        result = truncate("Hello World", 8, suffix=">>")        assert result == "Hello >>"        def test_truncate_exact_length(self):        """Test truncate with exact length."""        result = truncate("Hello", 5)        assert result == "Hello"

4. Testing Data Processor

# data_processor.pyclass DataProcessor:    def __init__(self):        self.data = []        def add_item(self, item):        if not isinstance(item, dict):            raise TypeError("Item must be a dictionary")                if 'id' not in item:            raise ValueError("Item must have 'id' field")                self.data.append(item)        def get_by_id(self, id):        for item in self.data:            if item['id'] == id:                return item        return None        def filter_by(self, key, value):        return [item for item in self.data if item.get(key) == value]        def count(self):        return len(self.data) # test_data_processor.pyimport pytestfrom data_processor import DataProcessor class TestDataProcessor:    def setup_method(self):        """Setup for each test."""        self.processor = DataProcessor()        def test_add_item_success(self):        """Test adding valid item."""        item = {'id': 1, 'name': 'Alice'}        self.processor.add_item(item)        assert self.processor.count() == 1        def test_add_item_invalid_type(self):        """Test adding invalid type raises TypeError."""        with pytest.raises(TypeError):            self.processor.add_item("not a dict")        def test_add_item_missing_id(self):        """Test adding item without id raises ValueError."""        with pytest.raises(ValueError):            self.processor.add_item({'name': 'Alice'})        def test_get_by_id_found(self):        """Test get_by_id when item exists."""        item = {'id': 1, 'name': 'Alice'}        self.processor.add_item(item)        result = self.processor.get_by_id(1)        assert result == item        def test_get_by_id_not_found(self):        """Test get_by_id when item doesn't exist."""        result = self.processor.get_by_id(999)        assert result is None        def test_filter_by(self):        """Test filtering items."""        self.processor.add_item({'id': 1, 'status': 'active'})        self.processor.add_item({'id': 2, 'status': 'inactive'})        self.processor.add_item({'id': 3, 'status': 'active'})                active = self.processor.filter_by('status', 'active')        assert len(active) == 2        assert all(item['status'] == 'active' for item in active)

5. Testing File Utilities

# file_utils.pyimport osimport json def read_json(filepath):    """Read JSON file."""    with open(filepath, 'r') as f:        return json.load(f) def write_json(filepath, data):    """Write JSON file."""    with open(filepath, 'w') as f:        json.dump(data, f, indent=2) def ensure_dir(directory):    """Create directory if it doesn't exist."""    if not os.path.exists(directory):        os.makedirs(directory) # test_file_utils.pyimport pytestimport osimport jsonimport tempfileimport shutilfrom file_utils import read_json, write_json, ensure_dir class TestFileUtils:    def setup_method(self):        """Create temp directory for tests."""        self.test_dir = tempfile.mkdtemp()        def teardown_method(self):        """Clean up temp directory."""        shutil.rmtree(self.test_dir)        def test_write_and_read_json(self):        """Test writing and reading JSON."""        filepath = os.path.join(self.test_dir, 'test.json')        data = {'name': 'Alice', 'age': 25}                write_json(filepath, data)        result = read_json(filepath)                assert result == data        def test_read_json_file_not_found(self):        """Test reading non-existent file raises error."""        with pytest.raises(FileNotFoundError):            read_json('nonexistent.json')        def test_ensure_dir_creates_directory(self):        """Test ensure_dir creates directory."""        new_dir = os.path.join(self.test_dir, 'new_directory')        ensure_dir(new_dir)        assert os.path.exists(new_dir)        def test_ensure_dir_existing_directory(self):        """Test ensure_dir with existing directory."""        ensure_dir(self.test_dir)  # Should not raise error        assert os.path.exists(self.test_dir)

Running Tests

# unittestpython -m unittest test_module.pypython -m unittest discover  # Auto-discover tests # pytestpytest                       # Run all testspytest test_file.py         # Run specific filepytest test_file.py::test_function  # Run specific testpytest -v                   # Verbose outputpytest -v -s                # Show print statementspytest -k "test_add"        # Run tests matching patternpytest -x                   # Stop after first failurepytest --maxfail=2          # Stop after 2 failures

Best Practices

# 1. Test one thing per testdef test_add():    assert add(2, 3) == 5  # Good - one assertion # 2. Use descriptive test namesdef test_divide_by_zero_raises_value_error():  # Good - clear    pass # 3. Arrange-Act-Assert patterndef test_user_creation():    # Arrange    username = "alice"    email = "[email protected]"        # Act    user = User(username, email)        # Assert    assert user.username == username # 4. Use fixtures for setup@pytest.fixturedef sample_user():    return User("alice", "[email protected]") def test_with_fixture(sample_user):    assert sample_user.username == "alice" # 5. Test edge casesdef test_edge_cases():    assert add(0, 0) == 0  # Zero    assert add(-1, 1) == 0  # Negative    assert add(999999, 1) == 1000000  # Large numbers # 6. Keep tests fast# - Mock external dependencies# - Use in-memory databases# - Avoid sleep() calls # 7. Test behaviors, not implementation# Good: Test what function doesdef test_sorts_list():    assert sort([3, 1, 2]) == [1, 2, 3] # Bad: Test how it does itdef test_uses_quicksort():  # Too specific    pass

Bài Tập Thực Hành

Bài 1: Test Calculator

Viết tests cho calculator:

  • Basic operations
  • Edge cases
  • Error handling
  • Parametrized tests

Bài 2: Test String Validator

Test validation functions:

  • Email validation
  • URL validation
  • Phone validation
  • Multiple test cases

Bài 3: Test Shopping Cart

Test shopping cart class:

  • Add/remove items
  • Calculate total
  • Apply discounts
  • Edge cases

Bài 4: Test File Handler

Test file operations:

  • Read/write files
  • Handle missing files
  • Parse different formats
  • Use temp files

Bài 5: Test API Wrapper

Test API wrapper (mocked):

  • Successful requests
  • Error handling
  • Retry logic
  • Response parsing

Tóm Tắt

Testing: Catch bugs early, prevent regressions
unittest: Built-in framework, TestCase class
pytest: Simple, powerful, assert-based
Assertions: assertEqual, assertTrue, assert
Organization: One test per behavior, descriptive names
Parametrization: Test multiple inputs efficiently
Best practices: AAA pattern, test behaviors, keep fast

Bài Tiếp Theo

Bài 13.2: Testing - Part 2 - Fixtures, mocking, test coverage, và TDD! 🚀


Remember:

  • Write tests for all critical code
  • Test one thing per test
  • Use descriptive names
  • Test edge cases
  • Keep tests fast and isolated! 🎯