Developer 101: Unit Testing Fundamentals

Learn what makes code testable, how to write effective unit tests using xUnit, unittest, or pytest, and avoid common pitfalls like hidden dependencies or flaky tests.

Unit tests are the foundation of reliable software development. They allow developers to make changes with confidence, support CI/CD automation, and catch bugs early in the development cycle. Yet many developers either skip writing tests or misunderstand their purpose—especially when it comes to mocking, test structure, and flaky behavior.

🔍 What Is a Unit Test?

A unit test verifies the behavior of a small, isolated piece of code—usually a single function or method—without relying on external systems like databases, file systems, or APIs.

What does "unit" mean? A unit refers to the smallest testable part of an application, such as a method, function, or class. It’s the individual building block of your code logic.

Effective unit tests target these isolated units to validate business logic without crossing module boundaries or introducing side effects.

Scope of Unit Testing:

  • One method or function at a time
  • No external dependencies (use mocks/stubs if needed)
  • Fast execution, often part of pre-commit hooks or CI pipelines

How It Differs from Integration Testing:

  • Unit Test: Tests one small unit in isolation, often with mocks and fakes.
  • Integration Test: Verifies that multiple modules or services work together, using real or test implementations of external components like databases or APIs.

Think of it this way: If your function fails a unit test, the issue is with your code logic, not with external dependencies. Integration test failures could stem from issues in interaction between multiple layers.

Think of it this way: If your function fails a unit test, the issue is with your code logic, not with external dependencies.

🧱 Arrange – Act – Assert (AAA Pattern)

This is a common pattern for structuring tests:

  • Arrange: Set up the test data and environment
  • Act: Call the method or function under test
  • Assert: Verify the result is what you expect

✅ Example in C# with xUnit

[Fact]
public void Add_ReturnsSum()
{
    // Arrange
    var calc = new Calculator();

    // Act
    var result = calc.Add(2, 3);

    // Assert
    Assert.Equal(5, result);
}
  

✅ Example in Python with unittest

import unittest

class CalculatorTest(unittest.TestCase):
    def test_add(self):
        calc = Calculator()
        result = calc.add(2, 3)
        self.assertEqual(result, 5)

if __name__ == '__main__':
    unittest.main()
  

🧪 Using Test Frameworks

xUnit is a popular testing framework for .NET developers. It integrates easily with Visual Studio and supports attributes like [Fact] and [Theory].

pytest and unittest are two widely-used testing libraries in Python. Pytest offers a simpler syntax and better plugin support.

✔ Sample Test with pytest

def test_multiply():
    calc = Calculator()
    assert calc.multiply(2, 5) == 10
  

🧰 Mocking Dependencies

Mocking is used to replace real dependencies (like databases or APIs) with controlled stand-ins.

🔁 C# Mocking with Moq

var mockRepo = new Mock();
mockRepo.Setup(r => r.GetUserById(1)).Returns(new User { Id = 1, Name = "Alice" });
  

🔁 Python Mocking with unittest.mock

from unittest.mock import MagicMock

repo = MagicMock()
repo.get_user_by_id.return_value = { 'id': 1, 'name': 'Alice' }
  

📊 Test Coverage and Flaky Tests

Code coverage shows how much of your source code runs during automated tests. It helps you find parts of your code that aren’t being tested. While high coverage is good, it doesn’t guarantee quality—assertions still need to be meaningful. Aim for strong tests that check important logic and edge cases. Tools like coverlet (.NET) and coverage.py (Python) help track coverage and can be used in CI pipelines.

Code Coverage Heatmap

Illustration: Visualizing code coverage with green (covered), yellow (partial), and red (uncovered) lines.

Flaky tests pass sometimes and fail at other times—often due to timing issues, reliance on shared state, or asynchronous bugs. Treat them as high-priority bugs!

🛠 How to Write Testable Code

Writing testable code starts with design. If your code is difficult to test, chances are it’s tightly coupled, has too many responsibilities, or relies too much on global state.

✅ Testable Code Patterns

  • Dependency Injection: Pass dependencies via constructor or method parameters instead of creating them inside your class.
  • Single Responsibility Principle: Break complex classes into smaller, focused components.
  • Pure Functions: Functions that have no side effects and return outputs based only on inputs are easiest to test.
  • Interfaces/Abstractions: Code against interfaces, not concrete classes, to make mocking easier.

🚫 Anti-Patterns to Avoid

  • Static/Global State: Hard to isolate and reset between tests.
  • Hidden Dependencies: Using new inside your business logic or database calls in constructors.
  • Too Much Logic in Constructors: Difficult to control and test object initialization behavior.
  • Mixing Concerns: Combining UI logic with business or data logic in one class.

🔄 Example: Refactor to Testable Code

❌ Non-Testable Code:

public class OrderService
{
    private readonly SqlConnection _connection;

    public OrderService()
    {
        _connection = new SqlConnection("...connection string...");
    }

    public void PlaceOrder(Order order)
    {
        // logic using _connection
    }
}
  

This is not testable because the SqlConnection is hardcoded and cannot be mocked.

✅ Refactored Testable Code:

public class OrderService
{
    private readonly IDbConnection _connection;

    public OrderService(IDbConnection connection)
    {
        _connection = connection;
    }

    public void PlaceOrder(Order order)
    {
        // logic using _connection
    }
}
  

Now you can inject a mock IDbConnection in your tests.

✅ Python Example:

# Non-testable
class AuthService:
    def __init__(self):
        self.db = Database()

    def login(self, username):
        return self.db.get_user(username)

# Refactored
class AuthService:
    def __init__(self, db):
        self.db = db

    def login(self, username):
        return self.db.get_user(username)
  

This version allows injecting a mock database in tests, improving testability.

💡 Best Practices

  • Keep tests isolated and fast
  • Use descriptive test names: ShouldReturnSum_WhenValidInput
  • Test both happy and edge cases
  • Mock only what's necessary
  • Run tests as part of CI/CD pipeline
  • Refactor your code to be more testable when needed

🎯 Conclusion

Unit testing helps ensure your code works as intended, simplifies debugging, and enables continuous delivery. Start small, focus on good structure, and adopt tools and patterns that scale with your project. Most importantly, write code that is easy to test—your future self will thank you.

Stay tuned for more Developer 101 lessons—only on TechWayFit.