How to write maintainable code without tests

Post on 28-Jan-2018

210 views 1 download

Transcript of How to write maintainable code without tests

How to write MAINTAINABLE CODE without tests

– That’s me.

“How can we keep moving - like musicians who keep performing during a concert after making

mistakes?”

JUTI NOPPORNPITAKSPEAKER SENIOR SOFTWARE DEVELOPER AT STATFLO

Awesome, the test automation is.It’s just like rehearsal. Practice makes you confident.

Sadly, not everyone has the luxury of time or the resources to

achieve test automation.Especially for those in the start-up community,

where every second counts.

This topic is not about ignoring test automation.

It is about how to design software before thinking

about testing.

Also, it is about what you should expect if writing tests is not an option.

ใ(ー 。ーใ)

MAINTAINABILITY

The maintainability of software

• isolate defects or causes, • correct defects or causes, • repair or replace defective

components without having to modify still working ones,

• make future maintenance easier, or • resist external changes.

Maintainability is a measure of the ease with which a

piece of software can be maintained in order to:

SANS TESTS

Maintainability without testsIn this case, all points become more difficult to achieve but it is still within the realm of possibility.

(ー x ー)ง

Keep things lean and simple!

When writing tests is not an option,then speed is all that matters.

In this case it is all about how fast any developers can understand the source code in a short amount of time so that they start either understanding or resolving the defects as soon as possible.

Keep things lean and simple!

When writing tests is not an option, then speed is all that matters.

No unnecessary defensive code

Defensive code usually guards against improper use of code components.

However, many of us are working with the internal code. As we have full access to the source code, misuse is less likely.

Keep things lean and simple!

When writing tests is not an option, then speed is all that matters.

No unnecessary defensive code

Only catch the exceptions you need.

Keep things lean and simple!

When writing tests is not an option, then speed is all that matters.

No unnecessary defensive code

Only catch the exceptions you need.

Keep the cyclomatic complexity low.

The low cyclomatic complexity of any given subroutine indicates that the subroutine is not too complicated as the number of linearly independent execution paths is very small.

Keep things lean and simple!

When writing tests is not an option, then speed is all that matters.

No unnecessary defensive code

Only catch the exceptions you need.

Hence, if you did it right…

Simple Implementation

Low Cyclomatic Complexity

Easily Understandable

Low Test Complexity

Keep the cyclomatic complexity low.

The cyclomatic complexity directly affects the test complexity.

When we say we have test coverage, the "coverage" is not about how many lines have been executed. It’s about how many independent execution paths are covered by test automation.

Practically, we should aim to achieve the minimum test coverage where there exists enough test cases to test all independent execution paths.

FUN FACT

Separation of Concerns

It is about separating a computer program into distinct sections such that each section addresses a separate concern. (Source: Wikipedia)

You can think of concerns as sub problems and sections as solutions to a corresponding sub problem.

Separation of Concerns

The benefits are (A) you can implement test cases for a particular section without testing the whole program or subroutine and (B) the program can be easily refactored.

In term of maintainability, the separation of concerns helps us isolate the program by concern.

Suppose we have the code to parse CLI arguments and configuration files.

import argparse, json

def main(): parser = argparse.ArgumentParser() parser.define('config_path') args = parser.parse_args()

with open(args.config_path, 'r') as f: config = json.load(f)

# ...

if __name__ == '__main__': main()

Let's separate them.

import argparse, json

def define_parser(): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def parse_json_config(config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

def main(): parser = define_parser() args = parser.parse_args() config = parse_json_config(args.config_path)

# ...

if __name__ == '__main__': main()

Define the CLI arguments

Load and parse the JSON file

The main script

Single Responsibility

– Robert C Martin

“Module or class should have responsibility on a single part of the functionality provided by the software, and responsibility should be entirely encapsulated by the

class. All of its services should be narrowly aligned with that responsibility.”

Basically, think of responsibility like

DUTYto solve one particular problem.

く(ー_ー)

Single responsibility isolates the program by functionality.

With proper design and implementation, any components of a computer program can be easily maintained with minimal or no impact on other

components.

You can use contract-based design (or Design by Contract) to define expected functionalities.

From the the previous example where each concern is clearly separated…

import argparse, json

def define_parser(): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def parse_json_config(config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

def main(): parser = define_parser() args = parser.parse_args() config = parse_json_config(args.config_path)

# ...

if __name__ == '__main__': main()

Define the CLI arguments

Load and parse the JSON file

The main script

After separating responsibilities...

# app/cli.py import argparse

class Console(object): def __init__(self, loader): self.loader = loader

def define_parser(self): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def run(self): parser = define_parser() args = parser.parse_args() config = self.loader.load(args.config_path)

# ...

# app/loader.py import json

class ConfigLoader(object): def load(self, config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

# main.py from app.cli import Console from app.loader import ConfigLoader

def main(): loader = ConfigLoader() console = Console(loader) console.run()

if __name__ == '__main__': main()

To handle user input from the terminal

To be the main application script

To handle configuration loading

Dependency Injection (DI)

Let’s say… We want to decouple our classes from their dependencies so that these

dependencies can be replaced or updated with minimal or no changes to the classes.

Additionally, if the time permits, we want to test our classes in isolation, without

using dependencies, e.g., unit tests.

Lastly, we do not want our classes to be responsible for locating and

managing dependency construction and resolution.

Service The object you want to use

Client The object that depends on other services

InjectorResponsible for constructing services and injecting them into the clients

General terms on roles in Dependency Injection

Previously in Single Responsibility

# app/cli.py import argparse

class Console(object): def __init__(self, loader): self.loader = loader

def define_parser(self): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def run(self): parser = define_parser() args = parser.parse_args() config = self.loader.load( args.config_path )

# ...

# app/loader.py import json

class ConfigLoader(object): def load(self, config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

# main.py from app.cli import Console from app.loader import ConfigLoader

def main(): loader = ConfigLoader() console = Console(loader) console.run()

if __name__ == '__main__': main()

# app/cli.py import argparse

class Console(object): def __init__(self, loader): self.loader = loader

def define_parser(self): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def run(self): parser = define_parser() args = parser.parse_args() config = self.loader.load( args.config_path )

# ...

# app/loader.py import json

class ConfigLoader(object): def load(self, config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

# main.py from app.cli import Console from app.loader import ConfigLoader

def main(): loader = ConfigLoader() console = Console(loader) console.run()

if __name__ == '__main__': main()

This module is acting as the injector.

# app/cli.py import argparse

class Console(object): def __init__(self, loader): self.loader = loader

def define_parser(self): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def run(self): parser = define_parser() args = parser.parse_args() config = self.loader.load( args.config_path )

# ...

# app/loader.py import json

class ConfigLoader(object): def load(self, config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

# main.py from app.cli import Console from app.loader import ConfigLoader

def main(): loader = ConfigLoader() console = Console(loader) console.run()

if __name__ == '__main__': main()

Define loader as a service without dependencies

# app/cli.py import argparse

class Console(object): def __init__(self, loader): self.loader = loader

def define_parser(self): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def run(self): parser = define_parser() args = parser.parse_args() config = self.loader.load( args.config_path )

# ...

# app/loader.py import json

class ConfigLoader(object): def load(self, config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

# main.py from app.cli import Console from app.loader import ConfigLoader

def main(): loader = ConfigLoader() console = Console(loader) console.run()

if __name__ == '__main__': main()

Define console as a service with loader as the only dependency.

# app/cli.py import argparse

class Console(object): def __init__(self, loader): self.loader = loader

def define_parser(self): parser = argparse.ArgumentParser() parser.define('config_path')

return parser

def run(self): parser = define_parser() args = parser.parse_args() config = self.loader.load( args.config_path )

# ...

# app/loader.py import json

class ConfigLoader(object): def load(self, config_path): with open(config_path, 'r') as f: config = json.load(f)

return config

# main.py from app.cli import Console from app.loader import ConfigLoader

def main(): loader = ConfigLoader() console = Console(loader) console.run()

if __name__ == '__main__': main()

As you can see, it is possible to replace loader with anything by injecting any services that satisfy the contract required by console, e.g., the replacement must have the method load whose the first argument is a string and the returning value is a dictionary.

Sadly, there are very few dependency-injection frameworks available for Python like:

• pinject (github.com/google/pinject) • imagination (github.com/shiroyuki/imagination).

These are a few foundations for achieving code maintainability

even if you do not have complete or usable test automation.

There are also many interesting patterns that can help you.

For example, SOLID Design Principles, Adapter Pattern, Factory Pattern, Repository & Unit of Work Pattern etc.

But always watch out for…

Code Readability

When time is not on our side, as humans are not capable of remembering something forever, we have to make sure that the source code is implemented clearly, in a way that’s readable and understandable in short time.

Messy source code may psychologically make software developers feel like it is complicated even when it isn’t.

Messy Python code can make you feel like you’re reading machine code.

def prep(u_list): return [ { key: getattr(u, k) if k != ‘stores’ else [ { ‘id’: s.id, ‘name’: s.name, } for s in s_list ] for k in (‘id’, ’fullname’, ‘email’, ‘stores’) } for u in u_list ]

Over-engineeringFor example, early/premature/excessive abstraction, attempts to solve universal-yet-unknown problems, etc.

Circular DependenciesSuppose two services depend on each other. This tight coupling between objects creates the "chicken & egg" situation.

The very first few problems are:

• No one know what should be constructed first. • Why are they separated in the first place?

Contact Information• Homepage: http://www.shiroyuki.com

• GitHub: @shiroyuki

• Twitter: @shiroyuki

• 500px: shiroyuki

• Medium: @shiroyuki

• LinkedIn: jnopporn

This link to this slide should be available soon at

https://www.shiroyuki.com/talks/201611-pycon-ca/

Statflo is actively hiring developers who are passionate about building and owning something great.

If you'd like to hear more, we'd love to talk.

Send an email to careers@statflo.com with “PyCon Application” in the subject line, and we'll reach out.

Looking for an opportunity to put these principles to work?