Hello, World
It is traditional for your first program in a new language to be Hello, World.
This chapter uses a very small program to introduce a lot of important Python ideas:
- writing and running a Python script
- separating logic from side effects so code is easier to test
- writing your first tests with
pytest - using the red / green / refactor TDD cycle
- making small, safe refactors
- adding type hints to make code easier to understand
Prerequisites
Before continuing, make sure you have:
- Python 3.8 or later installed
- a terminal you can run commands in
- a text editor or IDE set up for Python development
pytestinstalled in your active environment
If you have not already set up Python and a virtual environment, refer back to the installation chapter first.
Project structure
Create a folder wherever you like and add the following files as you work through the chapter:
Keeping the project this small helps you focus on the workflow rather than the tooling.
Create the first program
- Create a folder wherever you like
- Put a new file in it called
hello.pyand put the following code inside it
def main() -> None:
print("Hello, world")
if __name__ == "__main__":
main()
To run it, type python hello.py.
Depending on your system, you may need to use python3 hello.py instead.
How it works
When you write a program in Python, you define functions using the def keyword. In this example we defined a function called main.
def main() -> None:
print("Hello, world")
A few things are happening here:
def main()defines a function namedmain- the
-> Nonepart is a type hint saying this function does not return a value - the indented block underneath is the function body
print("Hello, world")writes text to standard output
The if __name__ == "__main__": pattern is Python's way of running code only when the file is executed directly.
if __name__ == "__main__":
main()
This means:
- if you run
python hello.py, Python executesmain() - if another file imports
hello.py, Python does not automatically runmain()
That makes the module easier to reuse and test.
Why separate logic from printing?
The print function outputs text to the console. That is a side effect: something observable happens outside your program.
In contrast, creating and returning a string is just logic. Logic is usually much easier to test than side effects.
A good beginner habit is to move as much decision-making and computation as possible into pure functions that return values. Then keep side effects like printing, reading files, network calls, or database access at the edges of the program.
Refactor to make testing easier
How do you test this? It is good to separate your "domain" code from the outside world (side-effects). The print() is a side effect (printing to stdout), and the string we send in is our domain.
So let's separate these concerns so it's easier to test:
def hello() -> str:
return "Hello, world"
def main() -> None:
print(hello())
if __name__ == "__main__":
main()
We have created a new function called hello, and this time, we're returning a str instead of printing it.
This is a small design improvement:
hello()is now responsible for creating the messagemain()is responsible for displaying it- we can test
hello()without capturing terminal output
How to test
Now create a new file called test_hello.py where we are going to write a test for our hello function:
from hello import hello
def test_hello() -> None:
got = hello()
want = "Hello, world"
assert got == want
Running the tests
To run the tests, use pytest:
pytest test_hello.py
You can also usually run all tests in the current project with:
pytest
For more verbose output:
pytest -v
If everything is working correctly, you should see output like:
=================== test session starts ====================
collected 1 item
test_hello.py . [100%]
==================== 1 passed in 0.01s =====================
Just to check, try deliberately breaking the test by changing the want string. You'll see pytest report the failure with a helpful diff showing what was expected vs. what was received.
Writing tests
Writing a test is just like writing a function, with a few conventions:
- it needs to be in a file with a name like
test_*.pyor*_test.py - the test function must start with the word
test_ - use
assertstatements to verify expected outcomes
We've covered some new topics.
assert
The assert statement checks if a condition is true. If it's false, the test fails.
Importing functions
We import our hello function from hello.py using from hello import hello.
Declaring variables
We're declaring some variables with the syntax got = hello(), which lets us reuse some values in our test for readability.
Using names like got and want is a common testing pattern because it makes failures easier to reason about.
TDD: red, green, refactor
From this point on, we will be following the TDD cycle more deliberately:
- Red: write a failing test for the new behavior
- Green: write the smallest amount of code to make the test pass
- Refactor: improve the design while keeping the tests green
This process helps you:
- confirm the requirement before writing implementation code
- avoid writing more code than you need
- change the design safely because your tests provide feedback
Hello, YOU
Now that we have a test, we can iterate on our software safely.
In the last example, we wrote the test after the code had been written so that you could get an example of how to write a test and declare a function. From this point on, we will be writing tests first.
Our next requirement is to let us specify the recipient of the greeting.
Let's start by capturing these requirements in a test. This is basic test-driven development and allows us to make sure our test is actually testing what we want.
from hello import hello
def test_hello() -> None:
got = hello("Chris")
want = "Hello, Chris"
assert got == want
Now run pytest, you should have a failure:
TypeError: hello() takes 0 positional arguments but 1 was given
When using Python, it is important to listen to the error messages. The error tells you exactly what's wrong: we're passing an argument to a function that doesn't accept any.
Edit the hello function to accept an argument:
def hello(name: str) -> str:
return "Hello, world"
If you try and run your tests again, you should see something like:
AssertionError: assert 'Hello, world' == 'Hello, Chris'
We have a failing test but the program runs. Let's make the test pass by using the name argument:
def hello(name: str) -> str:
return f"Hello, {name}"
Here we're using Python's f-string syntax for string formatting. The f prefix allows us to embed expressions inside {} braces.
When you run the tests, they should now pass. Normally, as part of the TDD cycle, we should now refactor.
Adding types
Python is dynamically typed, but it also supports optional type hints. Type hints do not usually change how your program runs, but they improve readability and allow tools to catch mistakes earlier.
For example:
def hello(name: str) -> str:
return f"Hello, {name}"
This tells readers and tools:
nameshould be astr- the function returns a
str
Type hints are especially helpful when:
- functions get larger
- code is shared across a team
- you revisit old code later
- you want editor support and static analysis
You can check your types using mypy if you have it installed:
mypy hello.py
As a beginner, you do not need to add types everywhere immediately. A good approach is:
- add types to new functions
- add return types to public functions first
- use your editor and tools to help fill in the rest over time
Constants
Let's introduce a constant:
ENGLISH_HELLO_PREFIX = "Hello, "
def hello(name: str) -> str:
return ENGLISH_HELLO_PREFIX + name
In Python, constants are conventionally written in UPPER_CASE. Python doesn't enforce immutability for constants, but this naming convention indicates that the value should not be changed.
After refactoring, re-run your tests to make sure you haven't broken anything.
Hello, world... again
The next requirement is when our function is called with an empty string, it defaults to printing "Hello, World", rather than "Hello, ".
Start by writing a new failing test:
from hello import hello
def test_hello_with_name() -> None:
got = hello("Chris")
want = "Hello, Chris"
assert got == want
def test_hello_empty_string() -> None:
got = hello("")
want = "Hello, World"
assert got == want
While we have a failing test, let's fix the code:
ENGLISH_HELLO_PREFIX = "Hello, "
def hello(name: str) -> str:
if name == "":
name = "World"
return ENGLISH_HELLO_PREFIX + name
If we run our tests we should see they both pass.
Refactoring tests
It is important that your tests are clear specifications of what the code needs to do. But there is repeated code when we check if the message is what we expect.
Refactoring is not just for the production code.
from hello import hello
def assert_correct_message(got: str, want: str) -> None:
assert got == want, f"got {got!r} want {want!r}"
def test_hello_with_name() -> None:
got = hello("Chris")
want = "Hello, Chris"
assert_correct_message(got, want)
def test_hello_empty_string() -> None:
got = hello("")
want = "Hello, World"
assert_correct_message(got, want)
We've refactored our assertion into a helper function. This reduces duplication and improves the readability of our tests. The !r format specifier in f-strings shows the repr() of a value, which can make failure messages more useful.
Adding types to test helpers can also make intent clearer, especially as a test suite grows.
More requirements
We now need to support a second parameter, specifying the language of the greeting. If a language is passed in that we do not recognize, just default to English.
Write a test for a user passing in Spanish:
def test_hello_in_spanish() -> None:
got = hello("Elodie", "Spanish")
want = "Hola, Elodie"
assert_correct_message(got, want)
When you try to run the test, you should get:
TypeError: hello() takes 1 positional argument but 2 were given
Fix by adding another parameter to hello:
def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"
return ENGLISH_HELLO_PREFIX + name
We've used a default parameter language="" so that existing calls to hello with just a name still work.
Now the test fails with:
AssertionError: assert 'Hello, Elodie' == 'Hola, Elodie'
Let's make it pass:
ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "
SPANISH = "Spanish"
def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"
if language == SPANISH:
return SPANISH_HELLO_PREFIX + name
return ENGLISH_HELLO_PREFIX + name
French
- Write a test asserting that if you pass in
"French"you get"Bonjour, " - See it fail, check the error message is easy to read
- Do the smallest reasonable change in the code
You may have written something that looks roughly like this:
ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "
FRENCH_HELLO_PREFIX = "Bonjour, "
SPANISH = "Spanish"
FRENCH = "French"
def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"
if language == SPANISH:
return SPANISH_HELLO_PREFIX + name
if language == FRENCH:
return FRENCH_HELLO_PREFIX + name
return ENGLISH_HELLO_PREFIX + name
Using a dictionary for mapping
When you have lots of if statements checking a particular value, it's common to use a dictionary for mapping instead:
ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "
FRENCH_HELLO_PREFIX = "Bonjour, "
SPANISH = "Spanish"
FRENCH = "French"
PREFIXES: dict[str, str] = {
SPANISH: SPANISH_HELLO_PREFIX,
FRENCH: FRENCH_HELLO_PREFIX,
}
def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"
prefix = PREFIXES.get(language, ENGLISH_HELLO_PREFIX)
return prefix + name
The dict.get() method returns the value for a key if it exists, otherwise returns the default value (second argument).
The type hint dict[str, str] says this dictionary maps strings to strings. This becomes more useful as your data structures grow more complex.
One last refactor
You could argue that maybe our function could be cleaner. The simplest refactor would be to extract the prefix lookup into another function:
ENGLISH_HELLO_PREFIX = "Hello, "
SPANISH_HELLO_PREFIX = "Hola, "
FRENCH_HELLO_PREFIX = "Bonjour, "
SPANISH = "Spanish"
FRENCH = "French"
PREFIXES: dict[str, str] = {
SPANISH: SPANISH_HELLO_PREFIX,
FRENCH: FRENCH_HELLO_PREFIX,
}
def _greeting_prefix(language: str) -> str:
"""Return the greeting prefix for the given language."""
return PREFIXES.get(language, ENGLISH_HELLO_PREFIX)
def hello(name: str, language: str = "") -> str:
if name == "":
name = "World"
return _greeting_prefix(language) + name
A few new concepts:
- the function name starts with an underscore
_greeting_prefix. In Python, a leading underscore indicates a "private" function - it's a convention that this function is internal and shouldn't be used outside the module - we've added a docstring to document what the function does
- we've kept the public function small and easy to read
Working in a virtual environment for this chapter
If you want to experiment safely, create a virtual environment inside your project folder:
python -m venv .venv
Activate it:
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell
.venv\Scripts\Activate.ps1
Install pytest:
pip install pytest
Then run your program and tests:
python hello.py
pytest -v
When you are done, leave the virtual environment with:
deactivate
This is a good way to experiment without affecting your global Python installation.
Common beginner mistakes and troubleshooting
Here are some common problems you may run into:
python: command not found
Try using python3 instead of python.
pytest: command not found
You may not have installed pytest in your current environment. Activate your virtual environment and run:
pip install pytest
Import errors
If from hello import hello fails, check that:
- the file is really named
hello.py - your test file is in the same folder
- you are running
pytestfrom the project directory
Indentation errors
Python uses indentation to define blocks. Make sure the code inside functions and if statements is consistently indented.
Tests pass but the program output is different than expected
Remember that hello() returns a string, while main() prints it. Be clear about which behavior you are testing.
Type checker complaints
If you start using mypy, its errors are often useful design feedback. They may show that a function can return more than one kind of value, or that your assumptions are unclear.
Exercises
Try these small extensions on your own:
- Add support for another language
- Add a new test before changing the implementation
- Change the default name from
"World"to something else and update the tests - Add a type hint to every function in the file
- Create a
main()function that accepts a name from the command line - Run
mypyand see if you can keep both tests and type checks passing
Wrapping up
Who knew you could get so much out of Hello, world?
By now you should have some understanding of:
Some of Python's syntax
- writing tests with pytest
- declaring functions with
def, with parameters and return values if, constants, and dictionaries- f-strings for string formatting
- default parameter values
- basic type hints like
str,None, anddict[str, str]
The TDD process and why the steps are important
- write a failing test and see it fail so we know we have written a relevant test for our requirements and seen that it produces an easy to understand description of the failure
- write the smallest amount of code to make it pass so we know we have working software
- then refactor, backed with the safety of our tests to ensure we have well-crafted code that is easy to work with
In our case, we've gone from hello() to hello("name") and then to hello("name", "French") in small, easy-to-understand steps.
Of course, this is trivial compared to real-world software, but the principles still stand. TDD is a skill that needs practice to develop, but by breaking problems down into smaller components and using tests as feedback, you can build that skill over time.