Defining Contracts: C# Abstract Classes & Interfaces

You've just seen the incredible flexibility of polymorphism, which allows different objects to respond uniquely to the same command. This powerful concept is enabled by another core pillar of Object-Oriented Programming, our final one to explore in this block: Abstraction.

Abstraction is the principle of hiding complex, unnecessary details and exposing only the essential features of an object or system. Think of the dashboard of a car. You have a simple interface: a steering wheel, pedals, and a speedometer. You understand the contract – pressing the accelerator makes the car go faster. You don't need to know about the fuel injection timing, the engine's combustion cycle, or the complex electronics managing it all. That complexity is abstracted away.

In C#, we have two primary tools to create these kinds of "contracts" for our code: abstract classes and interfaces. Let's learn how to use them to build more flexible and professional software. 💻

Tommy and Gina looking at an abstract holographic diagram of a robot body

The Principle of Abstraction

In programming, abstraction means focusing on the what an object does, rather than the how it does it. By creating abstractions, we can write code that is simpler to understand and more resilient to change. If the internal mechanics of the car's engine are improved, you, the driver, don't need to relearn how to use the steering wheel.

As you build a test automation framework, you'll want to use abstraction everywhere. You don't want every test script to know the messy details of launching a browser or reading a config file. Instead, you want to interact with simple, high-level components:

  • A BrowserManager with a StartBrowser() method.
  • A ConfigReader with a GetUrl() method.
  • A TestReporter with a LogPass() method.

The complex implementation details are hidden away behind this clean, abstract interface. Let's see how C# helps us build these contracts.

Abstract Classes – A Partial Blueprint

An abstract class is a special type of base class that serves as a common foundation for a family of related classes. It's considered "abstract" because it's incomplete on its own and is designed specifically to be inherited from.

Key Characteristics of an Abstract Class:

  • Cannot be Instantiated: You cannot create a direct object of an abstract class using the new keyword. It's a conceptual blueprint, not a finished product. You can't just have "a generic Shape"; you must have a concrete Circle or Square.
  • Can Contain Implemented Code: It can have regular methods, fields, and properties with full implementation. This is a key feature: it allows you to share common code among all its child classes.
  • Can Contain abstract Members: It can declare abstract methods or properties. These members are defined with a signature but no implementation (no code body). They establish a contract that any non-abstract child class must provide an implementation for, using the override keyword.

Use an abstract class when you have a strong "is-a" relationship (e.g., a LoginTests fixture is a type of BaseTest) and you want to provide some shared, common functionality to all children while also forcing them to define some of their own specific behaviors.

// You cannot write 'new BaseTest()'. This class is abstract.
public abstract class BaseTest
{
    // A regular property with implementation, shared by all children.
    public string TestSuiteName { get; protected set; }
 
    // A regular method with implementation.
    public void StartTestRun()
    {
        Console.WriteLine($"Starting test suite: {TestSuiteName}");
    }
 
    // An abstract method. It has no body here.
    // It FORCES any child class to provide its own version.
    public abstract void LoadTestData();
}
 
public class LoginTests : BaseTest
{
    public LoginTests()
    {
        TestSuiteName = "Login Functionality";
    }
 
    // This class MUST provide an implementation for the abstract LoadTestData method.
    public override void LoadTestData()
    {
        Console.WriteLine("Loading user credentials from login_users.json...");
    }
}   

Here, BaseTest provides a common starting mechanism but enforces that any real test fixture inheriting from it must define its own specific way of loading test data.

Virtual vs Abstract: Ace the Interview

This is a classic question in C# interviews because it tests your understanding of OOP principles. Here's how to remember the difference clearly:

  • A virtual method in a base class has an implementation. It provides a default behavior that a derived class can optionally override if it needs a more specialized behavior. Think of it as saying: "Here's a standard way to do this, but feel free to provide your own version if you want."
  • An abstract method has no implementation; it only defines a signature. It can only exist in an abstract class. It forces any non-abstract derived class to must provide an implementation. Think of it as saying: "I demand that you have this capability, and you are responsible for figuring out how to implement it."

In short:

Feature virtual Method abstract Method
Implementation? Yes, provides a default body. No, signature only.
Override? Optional Mandatory
Where it lives? Any non-sealed class Only in an abstract class

Interfaces – Defining a "Can-Do" Contract

While an abstract class provides a blueprint for a family of related objects, an interface is different. An interface is a pure contract of capabilities. It only defines what a class can do, not how it does it.

Think of an interface as a job description. It lists all the responsibilities that someone in that role must be able to perform (the methods and properties), but it says absolutely nothing about how they accomplish those tasks.

Key Characteristics of an Interface

  • It contains only definitions for members (method signatures, property definitions, etc.). It has no implementation code and no data fields.
  • A class or struct that implements an interface must provide a public implementation for all the members defined in that interface.
  • It's a way to achieve a form of multiple inheritance in C#, as a class can implement many interfaces at once.

Syntax and Naming Convention

By strong C# convention, interface names are prefixed with a capital I (e.g., ILoggable, IDisposable). You define one using the interface keyword, and a class implements it using the same colon : syntax as inheritance.

// The contract: anything that is ILoggable MUST have these two methods.
public interface ILoggable
{
    void LogMessage(string message);
    void LogError(string errorMessage);
}
 
// A class that promises to fulfill the ILoggable contract.
public class FileLogger : ILoggable
{
    // It MUST provide an implementation for all methods in the interface.
    public void LogMessage(string message)
    {
        // Logic to write the message to a text file...
        Console.WriteLine($"FILE_LOG: {message}");
    }
 
    public void LogError(string errorMessage)
    {
        // Logic to write the error message to a text file...
        Console.WriteLine($"FILE_ERROR_LOG: {errorMessage}");
    }
}
 
// A completely unrelated class can also implement the same contract.
public class DatabaseLogger : ILoggable
{
    public void LogMessage(string message)
    {
        // Logic to write the message to a database table...
        Console.WriteLine($"DB_LOG: {message}");
    }
 
    public void LogError(string errorMessage)
    {
        // Logic to write the error to a database table...
        Console.WriteLine($"DB_ERROR_LOG: {errorMessage}");
    }
}   

Both FileLogger and DatabaseLogger have completely different internal workings, but they both guarantee to the outside world that they can fulfill the ILoggable contract.

Default Interface Methods

In our main discussion, we've treated interfaces as pure contracts that contain no implementation code. For learning purposes, this is the best way to think about them! However, it's important to be aware of a feature introduced in C# 8.0 called default interface methods.

This feature allows you to add a method with a full body (implementation) directly inside an interface. Its primary purpose was to allow library creators to add new methods to existing, widely-used interfaces without creating a "breaking change" for everyone who had already implemented that interface.

While this is a powerful feature for library evolution, as you are starting out, it's best to stick to the classic distinction: use interfaces to define pure contracts (what a class can do) and use abstract classes when you need to share implementation details (how a class does something). Think of default interface methods as a specialized tool you'll appreciate more as you gain experience.

Abstract Class vs Interface – The Big Question

This is a classic question for C# learners. Both seem to define contracts, so when do you use which? Here's a clear comparison:

Inheritance vs Implementation

  • Abstract Class: A class can inherit from only one base class (abstract or not). This is for a direct parent-child lineage.
  • Interface: A class can implement multiple interfaces. This is for adding on different, often unrelated, capabilities.

Members and Implementation

  • Abstract Class: Can contain everything a normal class can: fields, constructors, and methods/properties with full implementation. It can also contain abstract members without implementation.
  • Interface: Contains only member definitions (signatures). It has no implementation code and no instance data fields. (While modern C# allows default interface methods, for our purposes, think of them as pure contracts).

Purpose and Relationship

  • Use an abstract class for a close "is-a" relationship, when you want to share common, implemented code among several closely related derived classes. For example, a ChromeDriver and a FirefoxDriver both are types of RemoteWebDriver (a base class in Selenium that likely has shared logic).
  • Use an interface to define a "can-do" capability that might be shared across completely unrelated classes. For example, a Car, a Robot, and a Dog are unrelated, but they could all implement an IMovable interface that requires them to have a Move() method.

When to Choose Which?

Here's a great rule of thumb: start by preferring an interface to define the public contract of what your object should be able to do. This keeps your design flexible. Only consider using an abstract class if you have a group of very closely related classes and you find yourself writing the same implementation code over and over again that you want to share in a common base.

Often, you'll see both used together: a class might inherit from a base abstract class to get some core functionality and also implement several interfaces to signal its other capabilities.

Interfaces, Polymorphism, and Test Automation

For test automation engineers, interfaces are not just an abstract concept; they are one of the most powerful tools for creating professional, maintainable, and flexible test frameworks. The primary benefit is creating loosely coupled code.

Loose coupling means that different parts of your system don't depend on the concrete, specific implementations of other parts; they only depend on the abstract contract (the interface).

The Power of Coding to an Interface

This leads to a powerful design principle called Dependency Inversion. Instead of your test directly creating a ChromeDriver object, it should work with an IWebDriver variable. IWebDriver is an interface provided by Selenium that defines all the things a web browser driver can do (like FindElement, Navigate, Close).

// Assume IWebDriver, ChromeDriver, FirefoxDriver exist from Selenium
public void RunLoginTest(IWebDriver driver) // This method depends on the INTERFACE, not a specific class
{
    // The test logic here uses methods defined in the IWebDriver interface.
    driver.Navigate().GoToUrl("https://mytestsite.com");
    IWebElement usernameField = driver.FindElement(By.Id("username"));
    usernameField.SendKeys("myuser");
    // ... and so on ...
    // This code doesn't care if 'driver' is Chrome, Firefox, or Edge!
}
 
// In your test execution logic, you can now "inject" the dependency:
IWebDriver chromeDriver = new ChromeDriver();
RunLoginTest(chromeDriver); // The method works with Chrome.
 
IWebDriver firefoxDriver = new FirefoxDriver();
RunLoginTest(firefoxDriver); // The SAME method now works with Firefox.    

This is incredibly powerful! It means you can easily switch out the browser implementation without changing a single line of your test logic. Even better, for fast unit tests of your test logic itself, you could create a "mock" driver that implements IWebDriver but doesn't actually open a browser. This makes your test framework flexible and easier to test itself.

Using interfaces to define contracts between the different layers of your test automation framework is a hallmark of professional-grade design.

Key Takeaways

  • Abstraction is a core OOP principle about hiding implementation complexity and exposing only essential functionalities through a contract.
  • An abstract class provides a partial blueprint for a family of related classes (an "is-a" relationship) and can contain both implemented code and abstract members that children must implement.
  • An interface defines a pure contract of capabilities (a "can-do" relationship) with no implementation details. A class can implement multiple interfaces.
  • Prefer interfaces for defining public capabilities; use abstract classes to share common code among closely related classes.
  • Coding to interfaces, not concrete classes, creates loosely coupled systems, which is crucial for building flexible, maintainable, and easily testable automation frameworks.

Defining Your Contracts

What's Next?

Excellent work! You've now journeyed through all four foundational pillars of OOP: Encapsulation, Inheritance, Polymorphism, and Abstraction. This gives you a powerful mental model for designing robust classes.

With this understanding of how to define clear contracts, let's refine a crucial step in the object lifecycle: its creation. Next, we'll look at some Advanced Object Creation techniques, such as using multiple constructors and initializers, to give you even more flexibility and control when building your objects.