Вы находитесь на странице: 1из 91

Flexible Design? Testable Design? You Don't Have to Choose!

Russ Rufer & Tracy Bialik


Google, Inc. GTAC 2010 Hyderabad, India

Code Health

Overview
Coming up... Defining Terms Analyzing example design decisions From specifics to generalization - Why it works Influencing development efforts Q&A

Flexible Design
Easy to Modify Extend Reuse Understand Changes Not Intrusive Don't explode complexity

Why Flexible Design Matters


Onboard developers quickly Respond quickly to unanticipated changes Extend system without explosion of complexity Safely modify without introducing lots of bugs

Testable Design
Focused on Unit Testing Many ideas extend to components/subsystems Easy to Instantiate class and collaborators Setup into the desired state Drive code through each execution path Witness effects

Good Unit Tests


Run quickly
In parallel when desired

Isolated from one another (hermetic) Pinpoint bugs (vs just indicating the system regressed) Easy to write and understand Validate
Happy paths Corner cases Exceptions

Why Is Unit Testability Important?


Isolates a piece of behavior for validation When a test breaks
Shows exactly what went wrong Keeps you out of the debugger

A Google motto. . .

Why Is Unit Testability Important?


Compared with end-to-end testing Isolates a piece of behavior for validation When a test breaks
Shows exactly what went wrong Keeps you out of the debugger

A Google motto: Debugging sucks, Testing rocks!

Aside
End-to-End Testing
End-to-end tests validate the big picture
Configuration Assembly Large-scale behavior

Unit tests Don't eliminate end-to-end testing Are effective for internal details Can reduce number of end-to-end tests

Design Choices...
For each of several design choices We'll introduce a Situation Propose Design 1
Why it has poor flexibility Why it has poor testability

Propose Design 2
Why flexibility has improved Why testability has improved

Music Collaboration Application Google Cacophony


A hypothetical new music authoring service needs to Process music change requests Parse user preference files Apply audio filters Incorporate changes into an existing composition Turn composition into playable midi file Send result back to each collaborating user

Grouping Responsibilities

Situation
How large should our classes be? I don't want to write a bunch of little classes for no reason

Grouping Responsibilities

Design 1: Not Recommended


Several behaviors in a huge class: MusicServer
/** * Process music change requests, Parse user preference files, * Apply audio filters, Incorporate changes into an existing * composition, Turn composition into playable midi file, and * Send result back to each collaborating user */ class Music Server { // a lot of code }

Grouping Responsibilities

Design 1: Poor Flexibility (1/2)


Reuse is inhibited - no unit of behavior can be shared elsewhere Suppose we add more features New music types (mp3, wav), more complex parsing, more audio filters, ... Unfortunate results Changing any aspect is intrusive Conditional complexity increases

Aside

Mutable Global Data


It's bad! (publicly mutable static data is essentially global)

Grouping Responsibilities

Design 1: Poor Flexibility (2/2)


Combining responsibilities yields large classes Many fields and methods Fields are essentially global within class Encapsulation is lost within this space
Code has access to unrelated fields it shouldn't

Same with private methods If many unrelated methods have access, how private is private?

Grouping Responsibilities

Design 1: Poor Testability (1/2)


Just Imagine... Your input is a change encoded in an RPC and your output is an mp3 file Testing the only unit is essentially end-to-end All tests compare against golden mp3s A regression sends you straight to the debugger

Grouping Responsibilities

Design 1: Poor Testability (2/2)


When you make intentional changes that should alter the final mp3 You have to create new versions of golden files
Costly if many output files change

Changing tests (e.g., the golden files) in lockstep with production code is an inferior safety net
It's difficult to know whether the new file you're generating as a golden file is actually valid

Grouping Responsibilities

Design 2: Preferred
Separate each distinct behavior into its own class
RequestHandler PreferenceReader PreferenceParser FilterSelector MusicMixer etc...

Aka Single Responsibility Principle Aka Separation of Concerns

Grouping Responsibilities

Design 2: Flexibility
Each class is easy to understand: conceptual chunk We regain the power that comes from OO features that support polymorphism and encapsulation If a responsibility has variable behavior
Further divided into an inheritance hierarchy Select a sibling dynamically

Grouping Responsibilities

Design 2: Testability (1/2)


With smaller classes, tests are more focused Easy to test unusual or exceptional behavior Easy to exercise corner cases/fencepost conditions Inputs for unit tests are easy to supply
Don't have to exercise every path with change requests

Output for most tests are easy to validate


No more golden mp3 files for unit tests

Grouping Responsibilities

Design 2: Testability (2/2)


An Example Design 1: How could you test the audio filter selection is always correct? Golden files would be very difficult to construct manually for every combination Design 2: Testing that a FilterSelector returns an appropriate filter for a variety of FilterSpecification is easy

Class needs Configuration Data from a File

Situation
A Class needs configuration data that's stored in a File How should we supply the information to the class?

Class needs Configuration Data from a File

Design 1: Not Recommended


The class (User) directly reads the data from a File Internally, User
Reads the file Parses Creates a Configuration object or data structure

These aren't User's core responsibility, so this is a smaller violation of Single Responsibility Principle It's such a pervasive example we want to call it out

Class needs Configuration Data from a File

Design 1: Code
class User { private Preferences prefs; public User(File preferenceFile) { prefs = parseFile(preferenceFile); } public void doSomething() { // using prefs } private Preferences parseFile {. . . } }

Class needs Configuration Data from a File

Design 1: Poor Flexibility


Difficult to support future extensions Changing file format = intrusive changes to User Supporting preference versions = more conditional logic in User User is tied to File-based preferences
What if we want to offload to a preference server?

Inhibited Reuse
We've buried reading, parsing and validation inside User

Class needs Configuration Data from a File

Design 1: Poor Testability


Tests that hit the file system are slow Hard to set up tests
need to make a file with the properly formatted data

Test File for each type of preference (or each important combination) If file format changes (e.g., a new piece of required data), update every test file

Class needs Configuration Data from a File

Design 2: Preferred
Direct injection of Preferences
class User { private Preferences prefs; public User(Preferences prefs) { this.prefs = prefs; } public void doSomething() { // using prefs } } We've just moved File parsing to the creator of User, we'll address this later

Class needs Configuration Data from a File

Design 2: Flexibility
User is stable if the file format changes Data can easily come from another source:
An RPC A Database An internal cache

Classes dedicated to each source can produce a Preferences object for User
Add a source = add an RpcPreferenceSource or DatabasePreferenceSource class

Class needs Configuration Data from a File

Design 2: Testability
Runs fast Preferences is trivial to initialize in a test
Much easier to create many variations for testing

Tests are no longer tied to the file format

Aside

General Input
Reasoning applies to other forms of Input
Raw sockets RPC mechanisms Data stores Etc Often: add an abstraction layer to isolate the input

Introduce a mediating class to retrieve input and supply transformed data to consuming classes Flexibility & Testability issues the same as for Files

Stateless Services and Utilities

Situation
One class in the system provides a stateless service that other classes in the system consume. How should behavior of that service be accessed?

Stateless Services and Utilities

Design 1: Not Recommended


Access the service through a static method Often seems easy at first to call a static method
Globally available Don't have to create an instance
So, it's easy to test the service, right?

Stateless Services and Utilities

Design 1: Code
If a music score is changed, inform Authors via email class MusicalScore { private void sendScoreUpdate() { // set up message ... EmailSender.send(message); } }

Stateless Services and Utilities

Design 1: Poor Flexibility 1/3


What happens when we have a new feature request? Depending on the Author's preference
Send a text message or Send an email message or Send a Google Buzz or Send a Twitter Tweet or ...

Stateless Services and Utilities

Design 1: Poor Flexibility 2/3


The typical option is to use an if/else:
class MusicalScore { private void sendUpdate() { // set up message ... if (sendText) TextSender.send(message); else if (sendEmail) EmailSender.send(message); else BuzzSender.send(message); } }

Stateless Services and Utilities

Design 1: Poor Flexibility 3/3


There's no instance
So we can't use Polymorphism We can't inject an appropriate Sender into MusicalScore

Another send recipient = another intrusive change to MusicalScore

Stateless Services and Utilities

Design 1: Poor Testability


Nave testers may think this improves testability You don't have to instantiate the service to test it But the consumer can't be tested in isolation Often the service is a complex or expensive operation that you want to avoid in unit tests Preventing email from actually being sent takes extra work and typically for testing code in the service

Stateless Services and Utilities

Caveat
Extremely simple stateless utilities don't cause the problems we're describing Math.abs() is fine as a static utility

Stateless Services and Utilities

Design 2: Preferred
Make the service instantiable Unify variant implementations in inheritance hierarchy Inject the appropriate Sender into a MusicalScore

Stateless Services and Utilities

Design 2: Code
class MusicalScore { public MusicalScore(Sender sender) { this.sender = sender} private Sender sender; private void sendUpdate() { // set up message sender.send(message); } }

Sender is the base class for EmailSender, TextSender, BuzzSender, etc. We'll explain later how to get the sender to the Score

Stateless Services and Utilities

Design 2: Flexibility
MusicalScore doesn't know each Sender's type Conditional logic about which sender to use is separated from a MusicalScore Adding new sender types, MusicalScore remains stable
no more intrusive changes

Stateless Services and Utilities

Design 2: Testability
Fakes or Mocks are possible
Avoid expensive operations (time, memory, etc.) Avoid undesirable side effects Easily simulate exceptional cases in the service for testing

Getting Data to Consumers

Situation
Many pieces of data needed by classes throughout the system
Data supplied on a command line Data in an initialization file Data from a request Anything derivable/discoverable from these

Each class just needs one or a few items

Getting Data to Consumers

Design 1 Stub: Not recommended


In a tiny system, you might weave one data item from main() down to the classes that need it Soon you have a second and third parameter for additional items needed through the system As the system uses more data, tacking on additional parameters is quickly unwieldy

Getting Data to Consumers

Design 1 (A/B/C): Not recommended


As systems grow, we see three typical bad designs to avoid exploding the number of items passed:
A) All items are bundled into a Context object that's passed through the system B) Chunks of data are packaged together and nested in other classes that are already widely known throughout the system C) Bucket of static data (a problem already discussed as global data)

Getting Data to Consumers

Design 1A: Context Object


As the number of items grows They're bundled into a huge Context object Context is passed through the whole system All parents touch context to pass it down
Even if they don't need it themselves

Consumers reach into context for the data they need

Getting Data to Consumers

Design 1A: Poor Flexibility


Context object is so widely known and used it might as well be global Public methods don't reveal what a class truly uses Parents that know the Context just to pass it to their children are unnecessarily complicated No class can be reused in a system without this context Law of Demeter violation: reaching into the guts of a class to pull something out

Getting Data to Consumers

Design 1B: Holders


Overload a well known class to carry bags of data Data is passed part way into the aggregate structure
E.g., User holds a Preferences

Often with more data, holders become nested


Preferences holds a LocationData LocationData holds a CountryCode

Classes like Account that require the country code dig it out of User

Getting Data to Consumers

Design 1B: Holders - Code


public Account(User user) { this.countryCode = user.getPreferences().getLocationData().getCountryCode(); }

Getting Data to Consumers

Design 1B: Poor Flexibility


Public methods don't reveal what a class truly uses
You can't tell which classes receiving User actually read or write a CountryCode

Digging code is often duplicated Digging code locks the structure it walks in place Deeper Law of Demeter violation

Getting Data to Consumers

Design 1(A & B): Poor Testability (1/2)


For both Context and Holders Tests are unnecessarily complicated
Every test needs to place the necessary data into its proper nested location, just so the production code can dig it back out

Getting Data to Consumers

Design 1(A & B): Poor Testability (2/2)


Test Code (Account only needs CountryCode from User) public void testAnything { CountryCode countryCode = new CountryCode(India); LocationData locationData = new LocationData(...); locationData.setCountryCode(countryCode); Preferences prefs = new Preferences(locationData); User user = new User(Jane Doe, prefs); Account account = new Account(user); // ... }

Bold code is the problem!

Getting Data to Consumers

Design 2: Preferred Design


Pass each class only the data it needs Flexibility and Testability problems clearly disappear
public void testAnything { CountryCode code = new CountryCode(India); Account account = new Account(code);

// } It may be easier said than done, now we'll talk about how

System Assembly
Top Down Bottom-Up
Ca r Winds hield Engi ne Spark plug Spark plug

System Assembly

Design 1: Top Down


Why is it hard to avoid the earlier poor design choices? The following structure shows us:

System Assembly

Design 1: Top Down Code 1/2


class Car { public Car() { engine = new Engine(); windshield = new Windshield(); } } class Engine { public Engine() { sparkplugs.add(new Sparkplug()); sparkplugs.add(new Sparkplug()); }

System Assembly

Design 1: Top Down Code 2/2


Constructing a Car is easy: Car car = new Car(); // fully assembled Car But changing the parts inside a Car is hard!

System Assembly: Top Down

Design 1: Poor Flexibility


Brittle way to assemble our system Now we want high performance sparkplugs But there's no way to construct the engine differently
Construction is hidden away inside the aggregate structure

Moving sparkplug construction up to Car doesn't help


Construction is still hidden away, it's just one level up now

System Assembly: Top Down

Design 1: Poor Testability


To use a Car, the entire aggregate is constructed
This might be expensive for every test

Can't supply test versions of collaborators


Stuck with the standard Windshield, Engine, etc.

System Assembly: Bottom Up

Design 2: Preferred Design


Dedicated creation mechanism Makes children first Construct their parents, injecting the children Recursive Dependency Injection Manually with Builders and Factories Automated by frameworks
Google Guice Spring, etc.

System Assembly: Bottom Up

Design 2: Code
class Engine { public Engine(Set<Sparkplug> sparkplugs) { this.sparkplugs = sparkplugs; } class Car { public Car(Engine engine, Windshield windshield) { this.engine = engine; this.windshield = windshield; } }

System Assembly: Bottom Up

Design 2: Flexibility
Easy to assemble Car with
FuelEfficientEngine or HighPerformanceEngine TintedWindshield or ClearWindshield

Similarly for Engine with


StandardSparkplugs, HighPerformanceSparkplugs

System Assembly: Bottom Up

Design 2: Testability
Easy to test pieces in isolation Easy to create fakes or mocks
Car with FakeEngine that refuses to start Spark plug that validates it was asked to fire

Supporting Covariant Behavior

Situation
Clusters of behaviors that change together Multiple sites where these behaviors are used Clarifying example...

Supporting Covariant Behavior

Example
Free, Basic, and Premium Accounts By account type, MusicMixer limits:
generated music duration available filters available instruments ...

Supporting Covariant Behavior

Design 1: Not recommended


class Director { private MusicMixer mixer; private AccountType accountType; private void truncateAudio() { // if (accountType == free) mixer.truncateAudioShort(); else if (accountType == basic) mixer.truncateAudioLong(); else // don't truncate for premium account } ...

Supporting Covariant Behavior

Design 1: Not recommended


still in Director private void setupInstruments() { if (accountType == free) mixer.useFreeInstruments(); else if (accountType == basic) mixer.useBasicInstruments(); else mixer.usePremiumInstruments(); // } ...

Supporting Covariant Behavior

Design 1: Not recommended


still in Director private void applyEchoFilter() { if (accountType == premium) mixer.applyFreeEchoFilter(); else if (accountType == basic) mixer.applyBasicEchoFilter(); else mixer.applyPremiumEchoFilter(); // ... }

Supporting Covariant Behavior

Design 1: Poor Flexibility


Changes are intrusive
Additional account type = add a clause to every conditional for the new account type

Logic is spread around so it's hard to understand everything about a given account type Invites more bugs!
We were charged for 1 million extra uses of premium echo filtering. Oops! Where was the bug?

Supporting Covariant Behavior

Design 1: Poor Testability (1/2)


Validate Director calls the correct Mixer methods
For every account type at every feature

Mock mixer to expect free, basic, or premium versions of every feature

Supporting Covariant Behavior

Design 1: Poor Testability (2/2)


Combinatorial explosion of tests Cross-product of features and account types
(Free/Basic/Premium) X (Truncation/Filtering/...)

A 3-way decision at each of 5 features = 15 tests Additional account type = another test per feature! Additional feature = another test per account type!

Supporting Covariant Behavior

Design 2: Preferred
Isolate Free, Basic and Premium behavior into separate classes
FreeMixer, BasicMixer, PremiumMixer

Use Polymorphism to make them interchangeable


MusicMixer interface with one method per feature

Supporting Covariant Behavior

Design 2: Preferred
class Director { private MusicMixer mixer; private void truncateAudio() { mixer.truncateAudio(); //... } private void applyEchoFilter() { mixer.applyEchoFilter(); //... }

Supporting Covariant Behavior

Design 2: Flexibility
Easy to support another type of Account
Director remains stable MusicMixer interface remains stable

All the logic for FreeMixer (or Basic or Premium) is together in one place Director is simpler (no conditional logic) Safer to make the mixer subclass decision in one place

Supporting Covariant Behavior

Design 2: Testability
Eliminated the combinatorial explosion of tests May write a test that validates when we have a premium account, we get a premium mixer (if not already clearly tested by end results) No longer need multiple tests to ensure that Director is calling appropriate MusicMixer method

Generalization

Why This Works?


3 Foundational Pillars of Object-Oriented Code Encapsulation Loose Coupling High Cohesion

Aside: Strong use of Polymorphism supports High Cohesion Encapsulation

Generalization

Music Server: Isolate Responsibilities


Music Server with all (or most code) in one class No Encapsulation Music Server split into multiple classes Encapsulation High Cohesion

Generalization

User and Preferences: Isolate Input


User reading Preferences file directly No Encapsulation of file parsing and format Couples User to file/format User supplied with Preferences data Encapsulation of file parsing and format User decoupled from file/format

Generalization

Musical Score: Avoid Static Service


MusicalScore using static Sender service Tight Coupling: MusicalScore and all Sender services
EmailSender, TextSender, BuzzSender, etc.

MusicalScore with injected Sender Loose Coupling


MusicalScore only knows Senders via an interface

Generalization

Minimize Context Object or Holder


Context Object or Holder (User with Location Data) No Encapsulation (digging into code) Tight Coupling Low Cohesion
Context and User are dumping grounds

Inject exactly what a class uses Encapsulation isn't violated Loose Coupling
Classes tied only to collaborators they use

Generalization

System Assembly: Prefer Bottom Up


System Assembly Top Down (Car creates Engine...) No Cohesion of assembly logic
Assembly distributed throughout system

Tight Coupling
Car is tied to engine types, engine is tied to sparkplug types

System Assembly Bottom up (Engine injected into Car) Assembly consolidated in Factories/Builders
separated from business logic

Loose Coupling
Car only knows Engine interface

Generalization

MusicMixer: Avoid Repeated Conditions


Director and MusicMixer with 3 Accounts Behavior for a given Account type isn't Cohesive
Selection logic is scattered through Director Behavior is scattered through MusicMixer

MusicMixer interface with 3 Mixer subclasses Encapsulation


Director no longer knows about Account details

High Cohesion
Behavior for each Account type is together in one class

Generalization

Summary
Encapsulation, Coupling, Cohesion are common themes Optimize for these
Flexibility increases Testability increases

Flexibility: Crisp units of behavior you can exchange, vary and reuse Testability: Small units that can be exercised through paths, with the ability to substitute collaborators, isolate behavior and witness results.

Influencing Development Efforts

Customization 1/2
Based on our experience across industry Working with 100s of individuals and teams Canned arguments and prepared speeches aren't enough Each case is unique. Tap into this to influence an engineer's design habits.

Influencing Development Efforts

Customization 2/2
To find the right convincing argument, understand Developer Team Their motivations The context they're working in Challenges on the horizon

Influencing Development Efforts (1/3)


First Understand What motivates this engineer
Testing
Our team really cares about testing, but our code is hard to test.

Good Design
I don't want to corrupt my design just to make it testable.

What context they're working in

Influencing Development Efforts (2/3)


How does this engineer learn?
Theoretical Explanation By Example

For Theoretical
First establish common ground Set the stage (as we did earlier, with global mutable state) Transition to a related issue

If By Example
Find examples in their code Make your points using their code

Influencing Development Efforts (3/3)


Choose a Strategy Find a test they can't write with the current design Show how your suggestion makes the test possible Find a feature the current design inhibits
awkward current design backlog feature a hypothetical extension

Show how your suggestion allows for easy extension

Conclusion
Extend these ideas to inform your own design decisions and influence your teams to build flexible and testable systems.

Q&A
Flexible Code? Testable Code? You Don't Have To Choose! Thank you!

Russ Rufer (russr@google.com) Tracy Bialik (tracyb@google.com)

Вам также может понравиться