From 09b033ec88aaf6e8373215ad64b6726643079232 Mon Sep 17 00:00:00 2001 From: K Siva Prasad Reddy Date: Tue, 5 Dec 2023 13:12:37 +0530 Subject: [PATCH] Initial commit --- .github/workflows/python.yml | 27 +++ .gitignore | 4 + .python-version | 1 + README.md | 41 +++- customers/customers.py | 53 ++++++ db/connection.py | 12 ++ .../index.adoc | 177 ++++++++++++++++++ requirements.txt | 18 ++ tests/__init__.py | 0 tests/test_customers.py | 39 ++++ 10 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/python.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 customers/customers.py create mode 100644 db/connection.py create mode 100644 guide/getting-started-with-testcontainers-for-python/index.adoc create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_customers.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..fd77563 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,27 @@ +name: Build + +on: + push: + branches: + - '**' +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Test with pytest + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..940014d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +venv/ +.pytest_cache/ + diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 3f4ff40..a2fb81d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,39 @@ -# tc-guide-getting-started-with-testcontainers-for-python -Getting started with Testcontainers for Java guide +# Getting started with Testcontainers for Python + +This is sample code for [Getting started with Testcontainers for Python](https://testcontainers.com/guides/getting-started-with-testcontainers-for-python) guide. + +## 1. Setup Environment + +* Make sure you have a [compatible Docker environment](https://www.testcontainers.org/supported_docker_environment/) installed. +* Python 3.12+ installed. + +For example: + +```shell +$ python --version +Python 3.12.0 +``` + +## 2. Setup Project + +* Clone the repository + +```shell +git clone https://github.com/testcontainers/tc-guide-getting-started-with-testcontainers-for-python.git +cd tc-guide-getting-started-with-testcontainers-for-python +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +* Open the **tc-guide-getting-started-with-testcontainers-for-python** project in your favorite IDE. + +## 3. Run Tests + +Run the command to run the tests. + +```shell +$ pytest +``` + +The tests should pass. diff --git a/customers/customers.py b/customers/customers.py new file mode 100644 index 0000000..dfa2768 --- /dev/null +++ b/customers/customers.py @@ -0,0 +1,53 @@ +from db.connection import get_connection + + +class Customer: + def __init__(self, cust_id, name, email): + self.id = cust_id + self.name = name + self.email = email + + def __str__(self): + return f"Customer({self.id}, {self.name}, {self.email})" + + +def create_table(): + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE customers ( + id serial PRIMARY KEY, + name varchar not null, + email varchar not null unique) + """) + conn.commit() + + +def create_customer(c: Customer): + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO customers (name, email) VALUES (%s, %s)", (c.name, c.email)) + conn.commit() + + +def get_all_customers() -> list[Customer]: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT * FROM customers") + return [Customer(cid, name, email) for cid, name, email in cur] + + +def get_customer_by_email(email) -> Customer: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT id, name, email FROM customers WHERE email = %s", (email,)) + (cid, name, email) = cur.fetchone() + return Customer(cid, name, email) + + +def delete_all_customers(): + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM customers") + conn.commit() diff --git a/db/connection.py b/db/connection.py new file mode 100644 index 0000000..ac5c962 --- /dev/null +++ b/db/connection.py @@ -0,0 +1,12 @@ +import os + +import psycopg + + +def get_connection(): + host = os.getenv("DB_HOST", "localhost") + port = os.getenv("DB_PORT", "5432") + username = os.getenv("DB_USERNAME", "postgres") + password = os.getenv("DB_PASSWORD", "postgres") + database = os.getenv("DB_NAME", "postgres") + return psycopg.connect(f"host={host} dbname={database} user={username} password={password} port={port}") diff --git a/guide/getting-started-with-testcontainers-for-python/index.adoc b/guide/getting-started-with-testcontainers-for-python/index.adoc new file mode 100644 index 0000000..5204eed --- /dev/null +++ b/guide/getting-started-with-testcontainers-for-python/index.adoc @@ -0,0 +1,177 @@ +--- +title: "Getting started with Testcontainers for Python" +date: 2023-12-04T09:39:58+05:30 +draft: false +description: This guide will help you to get started with Testcontainers for Python by demonstrating how you can use PostgreSQL for testing. +repo: https://github.com/testcontainers/tc-guide-getting-started-with-testcontainers-for-python +languages: + - Python +tags: + - postgresql +--- +:toc: +:toclevels: 2 +:codebase: https://raw.githubusercontent.com/testcontainers/tc-guide-getting-started-with-testcontainers-for-python/main + +Testcontainers is an open-source framework for provisioning throwaway, on-demand containers for development and testing use cases. +Testcontainers makes it easy to work with databases, message brokers, web browsers, or just about anything +that can run in a Docker container. + +Using Testcontainers, you can write tests talking to the same type of services you use in production +without mocks or in-memory services. + +[NOTE] +If you are new to Testcontainers then please +read https://testcontainers.com/guides/introducing-testcontainers[What is Testcontainers, and why should you use it?] +to learn more about Testcontainers. + +Let us create a simple Python application that uses PostgreSQL database to store customers information. +Then we will learn how to use Testcontainers for testing with a real Postgres database. + +== Create a Python application + +Let's create a Python project and use the venv module to create a virtual environment for our project. +By using a virtual environment, we can avoid installing dependencies globally, +and also we can use different versions of the same package in different projects. + +[source,shell] +---- +mkdir tc-python-demo +cd tc-python-demo +python3 -m venv venv +source venv/bin/activate +---- + +We are going to use https://www.psycopg.org/psycopg3/[psycopg3] for talking to the Postgres database, +https://pytest.org/[pytest] for testing, +and https://testcontainers-python.readthedocs.io/en/latest/README.html[testcontainers-python] for running a PostgreSQL database in a container. + +Once the virtual environment is activated, we can install the required dependencies using pip as follows: + +[source,shell] +---- +$ pip install psycopg pytest testcontainers-postgres +$ pip freeze > requirements.txt +---- + +Once the dependencies are installed, we have used *pip freeze* command to generate the *requirements.txt* file +so that others can install the same versions of packages simply using *pip install -r requirements.txt*. + +== Implement Database Helper +Let's create *db/connection.py* file and create a function to get database connection as follows: + +[source,python] +---- +include::{codebase}/db/connection.py[] +---- + +Instead of hard-coding the database connection parameters, we are using environment variables to get the database connection parameters. +This will help us to run the application in different environments without changing the code. + +== Implement business logic + +Let's create *customers/customer.py* file and create *Customer* class as follows: + +[source,python] +---- +include::{codebase}/customers/customer.py[lines="4..12"] +---- + +Now, let's implement *create_table()* function to create *customers* table as follows: + +[source,python] +---- +include::{codebase}/customers/customer.py[lines="1..3,4..24"] +---- + +We have obtained a new database connection using *get_connection()* function and created a *customers* table. +We have used Python context manager *with* statement to automatically close the database connection once the table is created. + +Now, let's implement *create_customer()*, *get_all_customers()*, *get_customer_by_email()*, +and *delete_all_customers()* functions as follows: + +[source,python] +---- +include::{codebase}/customers/customer.py[lines="26..54"] +---- + +We have implemented various functions to insert, fetch, and delete customer records from the database +using Python's DB-API. + +[NOTE] +To keep it simple for the purpose of this guide, we are creating a new connection for every database operation. +In a real-world application, it is recommended to use a connection pool to reuse connections. + +== Write tests using Testcontainers +To test our functions, we will create a Postgres database in a container using Testcontainers once. +Then we will use the same database for all the tests. +Also, we will delete all the customer records before every test to run the tests in a predictable state. + +We are going to use pytest fixtures for implementing the setup and teardown logic. + +Let's create *tests/test_customers.py* file and implement the fixtures as follows: + +[source,python] +---- +include::{codebase}/tests/test_customers.py[lines="1..26"] +---- + +We have used *module* scoped fixture to create a PostgreSQL container using Testcontainers. +In the *setup()* fixture function, all the statements before *yield* will be executed before running any test. +All the statements after *yield* will be executed after running all the tests in the module. + +In the *setup_data()* fixture function, we are deleting all the records in the *customers* table. +This is a *function* scoped fixture, which will be executed before running every test. + +Now let's implement the tests as follows: + +[source,python] +---- +include::{codebase}/tests/test_customers.py[lines="28..40"] +---- + +* In the *test_get_all_customers()* test, we are inserting 2 customer records into the database, + fetching all the existing customers, and asserting the number of customers. +* In the *test_get_customer_by_email()* test, we are inserting a customer record into the database, + fetching the customer by email, and asserting the customer details. + +As we are deleting all the customer records before every test, the tests can be run in any order. + +== Run tests +To enable the Pytest https://pytest.org/explanation/goodpractices.html#test-discovery[auto-discovery] mechanism, create *__init__.py* file under *tests* directory with empty content. + +Now let's run the tests using pytest as follows: + +[source,shell] +---- +$ pytest +---- + +You should see the following output: + +[source,shell] +---- +pytest +=============================== test session starts =============================== +platform darwin -- Python 3.12.0, pytest-7.4.3, pluggy-1.3.0 +rootdir: /Users/siva/dev/tc-python-demo +collected 2 items + +tests/test_customers.py .. [100%] + +================================ 2 passed in 3.02s ================================ +---- + +== Conclusion + +We have explored how to use *testcontainers-python* library for testing a Python application using a PostgreSQL database. +In addition to PostgreSQL, testcontainers-python provides dedicated modules to many commonly used SQL databases, NoSQL databases, messaging queues, etc. +You can use Testcontainers to run any containerized dependency for your tests! + +You can explore more about Testcontainers at https://www.testcontainers.com/. + +== Further Reading +* https://testcontainers.com/guides/getting-started-with-testcontainers-for-java/[Getting started with Testcontainers for Java] +* https://testcontainers.com/guides/getting-started-with-testcontainers-for-dotnet/[Getting started with Testcontainers for .NET] +* https://testcontainers.com/guides/getting-started-with-testcontainers-for-go/[Getting started with Testcontainers for Go] +* https://testcontainers.com/guides/getting-started-with-testcontainers-for-nodejs/[Getting started with Testcontainers for Node.js] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..90ea308 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +certifi==2023.11.17 +charset-normalizer==3.3.2 +docker==6.1.3 +idna==3.6 +iniconfig==2.0.0 +packaging==23.2 +pluggy==1.3.0 +psycopg==3.1.14 +psycopg2-binary==2.9.9 +pytest==7.4.3 +requests==2.31.0 +SQLAlchemy==2.0.23 +testcontainers-core==0.0.1rc1 +testcontainers-postgres==0.0.1rc1 +typing_extensions==4.8.0 +urllib3==2.1.0 +websocket-client==1.7.0 +wrapt==1.16.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_customers.py b/tests/test_customers.py new file mode 100644 index 0000000..2da3cc1 --- /dev/null +++ b/tests/test_customers.py @@ -0,0 +1,39 @@ +import os +import pytest +from testcontainers.postgres import PostgresContainer + +from customers import customers + +postgres = PostgresContainer("postgres:16-alpine") + + +@pytest.fixture(scope="module", autouse=True) +def setup(): + postgres.start() + os.environ["DB_HOST"] = postgres.get_container_host_ip() + os.environ["DB_PORT"] = postgres.get_exposed_port(5432) + os.environ["DB_USERNAME"] = postgres.POSTGRES_USER + os.environ["DB_PASSWORD"] = postgres.POSTGRES_PASSWORD + os.environ["DB_NAME"] = postgres.POSTGRES_DB + customers.create_table() + yield + postgres.stop() + + +@pytest.fixture(scope="function", autouse=True) +def setup_data(): + customers.delete_all_customers() + + +def test_get_all_customers(): + customers.create_customer(customers.Customer(0, "Siva", "siva@gmail.com")) + customers.create_customer(customers.Customer(0, "James", "james@gmail.com")) + customers_list = customers.get_all_customers() + assert len(customers_list) == 2 + + +def test_get_customer_by_email(): + customers.create_customer(customers.Customer(0, "John", "john@gmail.com")) + customer = customers.get_customer_by_email("john@gmail.com") + assert customer.name == "John" + assert customer.email == "john@gmail.com"