Package: pytest-docker-tools
pytest-docker-toolsYou have written a software application (in any language) and have packaged it as a Docker image. Now you want to smoke test the built image or do some integration testing with other containers before releasing it. You:want to reason about your environment in a similar way to a docker-compose.yml`want the environment to be automatically created and destroyed as tests runwant the option to reuse previously created resources (e.g. containers) when executing tests in high frequencydon't want to have to write loads of boilerplate code for creating the test environmentwant to be able to run the tests in parallelwant the tests to be reliablepytest-docker-toolsis a set of opinionated helpers for creatingpy.testfixtures for your smoke testing and integration testing. It strives to keep your environment definition declarative, like a docker-compose.yml. It embraces py.test fixture overloading.It tries not to be too magical. It ended up kind of magical, but no more so thatpy.testitself.The main interface provided by this library is a set of 'fixture factories'. It provides a 'best in class' implementation of a fixture, and then allows you to treat it as a template - injecting your own configuration declaratively. You can define your fixtures in yourconftest.pyand access them from all your tests, and you can override them as needed in individual test modules.The API is straightforward and implicitly captures the dependencies between fixtures in the specification. For example, here is how it might look if you were building out a microservice and wanted to point its DNS and a mock DNS server:# conftest.pyfromhttp.clientimportHTTPConnectionimportpytestfrompytest_docker_toolsimportbuild,containerfakedns_image=build(path='examples/resolver-service/dns',)fakedns=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'127.0.0.1',})apiserver_image=build(path='examples/resolver-service/api',)apiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None,},dns=['{fakedns.ips.primary}'])@pytest.fixturedefapiclient(apiserver):port=apiserver.ports['8080/tcp'][0]returnHTTPConnection(f'localhost:{port}')You can now create a test that exercises your microservice:# test_smoketest.pyimportsocketdeftest_my_frobulator(apiserver):sock=socket.socket()sock.connect(('127.0.0.1',apiserver.ports['8080/tcp'][0]))deftest_my_frobulator_works_after_restart(apiserver):apiserver.restart()sock=socket.socket()sock.connect(('127.0.0.1',apiserver.ports['8080/tcp'][0]))In this example all the dependencies will be resolved in order and once per session:The latestredis:latestwill be fetchedA container image will be build from theDockerfilein thedbfolder.Then once per test:A new volume will be createdA new 'backend' container will be created fromredis:latest. It will be attached to the new volume.A new 'frontend' container will be created from the freshly built container. It will be given the IP if the backend via an environment variable. Port 3679 in the container will be exposed as an ephemeral port on the host.The test can then run and access the container via its ephemeral high port. At the end of the test the environment will be thrown away.If the test fails thedocker logsoutput from each container will be captured and added to the test output.In the example you'll notice we defined anapiclientfixture. Of course if you use that it will implicitly pull in both of the server fixtures and 'just work':# test_smoketest.pyimportjsondeftest_api_server(apiclient):apiclient.request('GET','/')response=apiclient.getresponse()assertresponse.status==200assertjson.loads(response.read())=={'result':'127.0.0.1'}ScopeAll of the fixture factories take thescopekeyword. Fixtures created with these factories will behave like any py.test fixture with that scope.In this example we create a memcache that issessionscoped and another that ismodulescoped.# conftest.pyfrompytest_docker_toolsimportcontainer,fetchmemcache_image=fetch(repository='memcached:latest')memcache_session=container(image='{memcache_image.id}',scope='session',ports={'11211/tcp':None,},)memcache_module=container(image='{memcache_image.id}',scope='module',ports={'11211/tcp':None,},)Whentest_scope_1.pyruns neither container is running so a new instance of each is started. Their scope is longer than a singlefunctionso they are kept alive for the next test that needs them.# test_scope_1.pyimportsocketdeftest_session_1(memcache_session):sock=socket.socket()sock.connect(('127.0.0.1',memcache_session.ports['11211/tcp'][0]))sock.sendall(b'set mykey 0 600 4\r\ndata\r\n')sock.sendall(b'get mykey\r\n')assertsock.recv(1024)==b'STORED\r\nVALUE mykey 0 4\r\ndata\r\nEND\r\n'sock.close()deftest_session_2(memcache_session):sock=socket.socket()sock.connect(('127.0.0.1',memcache_session.ports['11211/tcp'][0]))sock.sendall(b'set mykey 0 600 4\r\ndata\r\n')sock.sendall(b'get mykey\r\n')assertsock.recv(1024)==b'STORED\r\nVALUE mykey 0 4\r\ndata\r\nEND\r\n'sock.close()deftest_module_1(memcache_module):sock=socket.socket()sock.connect(('127.0.0.1',memcache_module.ports['11211/tcp'][0]))sock.sendall(b'set mykey 0 600 4\r\ndata\r\n')sock.sendall(b'get mykey\r\n')assertsock.recv(1024)==b'STORED\r\nVALUE mykey 0 4\r\ndata\r\nEND\r\n'sock.close()deftest_module_2(memcache_module):sock=socket.socket()sock.connect(('127.0.0.1',memcache_module.ports['11211/tcp'][0]))sock.sendall(b'set mykey 0 600 4\r\ndata\r\n')sock.sendall(b'get mykey\r\n')assertsock.recv(1024)==b'STORED\r\nVALUE mykey 0 4\r\ndata\r\nEND\r\n'sock.close()Whentest_scope_2.pyruns thesessionscoped container is still running, so it will be reused. But we are now in a new module now so themodulescoped container will have been destroyed. A new empty instance will be created.# test_scope_2.pyimportsocketdeftest_session_3(memcache_session):sock=socket.socket()sock.connect(('127.0.0.1',memcache_session.ports['11211/tcp'][0]))sock.sendall(b'get mykey\r\n')assertsock.recv(1024).endswith(b'END\r\n')sock.close()deftest_module_3(memcache_module):sock=socket.socket()sock.connect(('127.0.0.1',memcache_module.ports['11211/tcp'][0]))sock.sendall(b'get mykey\r\n')assertsock.recv(1024)==b'END\r\n'sock.close()ParallelismIntegration and smoke tests are often slow, but a lot of time is spent waiting. So running tests in parallel is a great way to speed them up.pytest-docker-toolsavoids creating resource names that could collide. It also makes it easy to not care what port your service is bound to. This means its a great fit for use withpytest-xdist.Here is a bare minimum example that just tests creating and destroying 100 instances of a redis fixture that runs under xdist. Create atest_xdist.pyplugin:importpytestfrompytest_docker_toolsimportcontainer,fetchmy_redis_image=fetch(repository='redis:latest')my_redis=container(image='{my_redis_image.id}',)@pytest.mark.parametrize("i",list(range(100)))deftest_xdist(i,my_redis):assertmy_redis.status=="running"And invoke it with:pytesttest_xdist.py-nautoIt will create a worker per core and run the tests in parallel:===================================== test session starts ======================================
platform darwin -- Python 3.6.5, pytest-3.6.3, py-1.5.4, pluggy-0.6.0
rootdir: ~/pytest-docker-tools, inifile:
plugins: xdist-1.22.2, forked-0.2, docker-tools-0.0.2
gw0 [100] / gw1 [100] / gw2 [100] / gw3 [100] / gw4 [100] / gw5 [100] / gw6 [100] / gw7 [100]
scheduling tests via LoadScheduling
......................................................................................... [ 82%]
...........                                                                              [100%]
================================= 100 passed in 70.08 seconds ==================================Factories ReferenceContainersTo create a container in your tests use thecontainerfixture factory.frompytest_docker_toolsimportcontainermy_microservice_backend=container(image='redis:latest')The default scope for this factory isfunction. This means a new container will be created for each test.Thecontainerfixture factory supports all parameters that can be passed to the docker-pyrunmethod. Seeherefor them all.Any string variables are interpolated against other defined fixtures. This means that a fixture can depend on other fixtures, and they will be built and run in order.For example:frompytest_docker_toolsimportcontainer,fetchredis_image=fetch(repository='redis:latest')redis=container(image='{redis_image.id}')deftest_container_starts(redis):assertredis.status=="running"This will fetch the latestredis:latestfirst, and then run a container from the exact image that was pulled. Note that if you don't usebuildorfetchto prepare a Docker image then the tag or hash that you specify must already exist on the host where you are running the tests. There is no implicit fetching of Docker images.The container will be ready when the test is started, and will be automatically deleted after the test has finished.If for some reason it's not ready in the timeout period (30 seconds by default) the test will fail.timeoutcan be passed to thecontainerfactory:frompytest_docker_toolsimportcontainer,fetchredis_image=fetch(repository='redis:latest')redis=container(image='{redis_image.id}',timeout=30)deftest_container_starts(redis):assertredis.status=="running"To create a container defining itsDockerfilein code:importiofrompytest_docker_toolsimportbuild,containerdockerfile=io.BytesIO(b"""FROM alpine:3.12RUN apk --no-cache add python3CMD ["tail", "-f", "/dev/null"]""")image=build(fileobj=dockerfile)container=container(image='{image.id}')deftest_container_starts(container):assertcontainer.status=="running"Ip AddressesIf your container is only attached to a single network you can get its Ip address through a helper property. Given this environment:# conftest.pyfrompytest_docker_toolsimportcontainer,fetch,networkredis_image=fetch(repository='redis:latest')backend_network=network()redis=container(image='{redis_image.id}',network='{backend_network.name}',)You can access the IP via the container helper:importipaddressdeftest_get_service_ip(redis):# This will raise a ValueError if not a valid ipipaddress.ip_address(redis.ips.primary)If you want to look up its ip address by network you can also access it more specifically:importipaddressdeftest_get_service_ip(backend_network,redis):ipaddress.ip_address(redis.ips[backend_network])PortsThe factory takes the same port arguments as the official Python Docker API. We recommend using the ephemeral high ports syntax:# conftest.pyfrompytest_docker_toolsimportcontainerapiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None})Docker will map port 8080 in the container to a random port on your host. In order to access it from your tests you can get the bound port from the container instance:deftest_connect_my_service(apiserver):assertapiserver.ports['8080/tcp'][0]!=8080LogsYou can inspect the logs of your container with the logs method:frompytest_docker_toolsimportcontainer,fetchredis_image=fetch(repository='redis:latest')redis=container(image='{redis_image.id}',ports={'6379/tcp':None},)deftest_logs(redis):assert'oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo'inredis.logs()ImagesTo pull an image from your default repository use thefetchfixture factory. To build an image from local source use thebuildfixture factory. If you are smoke testing an artifact already built locally you can use theimagefixture factory to reference it.frompytest_docker_toolsimportbuild,image,fetchmy_image=fetch(repository='redis:latest')my_image_2=build(path='db')Thebuildfixture factory supports all parameters that can be passed to the docker-pybuildmethod. Seeherefor them all. Thefetchfixture factory supports all parameters that can be passed to the docker-pypullmethod. Seeherefor them all.The default scope for this factory issession. This means the fixture will only build or fetch once per py.test invocation. The fixture will not be triggered until a test (or other fixture) tries to use it. This means you won't waste time building an image if you aren't running the test that uses it.CachingBy default images are kept between invocations. This speeds things up a lot. But when doing incremental development of an image it can leave you with lots of orphaned layers. Runningdocker image prunewill throw away these layers, but as that is all untagged images it will include ones that are still valid for you current project. For large images this can really slow down your test cycle.To avoid this you need to tag your images asdocker image prunewon't throw away tagged images by default. But for this to be effective for multi-stage images, you need to tag your stages as well. To support this, pytest-docker-tools takes astagesparameter. For example:frompytest_docker_toolsimportbuild,image,fetchmy_image=build(path='db',tag='localhost/myproject:latest',stages={'builder':'localhost/myproject:builder'})Under the hood this will make pytest-docker-tools first build (and tag) thebuilderstage. This is like running:dockerbuild--targetbuilder--taglocalhost/myproject:builder.Then when that is tagged it will run the default target as before, which is like running:dockerbuild--taglocalhost/myproject:latest.This will reuse the layers generated in the previous build (where applicable).Now when you rundocker image pruneboth the latest image build and the latest versions of the stages it depends on are left alone.NetworksBy default any containers you create with thecontainer()fixture factory will run on your default docker network. You can create a dedicated network for your test with thenetwork()fixture factory.frompytest_docker_toolsimportcontainer,fetch,networkfrontend_network=network()redis_image=fetch(repository='redis:latest')redis=container(image='{redis_image.id}',network='{frontend_network.name}',)Thenetworkfixture factory supports all parameters that can be passed to the docker-py networkcreatemethod. Seeherefor them all.The default scope for this factory isfunction. This means a new network will be created for each test that is executed.The network will be removed after the test using it has finished.VolumesIn the ideal case a Docker container instance is read only. No data inside the container is written to, if it is its to a volume. If you are testing that your service can run read only you might want to mount a rw volume. You can use thevolume()fixture factory to create a Docker volume with a lifecycle tied to your tests.frompytest_docker_toolsimportvolumebackend_storage=volume()Thevolumefixture factory supports all parameters that can be passed to the docker-py volumecreatemethod. Seeherefor them all.In addition you can specify ainitial_contentdictionary. This allows you to seed a volume with a small set of initial state. In the following example we'll preseed a minio service with 2 buckets and 1 object in 1 of those buckets.frompytest_docker_toolsimportcontainer,fetch,volumeminio_image=fetch(repository='minio/minio:latest')minio_volume=volume(initial_content={'bucket-1':None,'bucket-2/example.txt':b'Test file 1',})minio=container(image='{minio_image.id}',command=['server','/data'],volumes={'{minio_volume.name}':{'bind':'/data'},},environment={'MINIO_ACCESS_KEY':'minio','MINIO_SECRET_KEY':'minio123',},)deftest_volume_is_seeded(minio):files=minio.get_files('/data')assertfiles['data/bucket-2/example.txt']==b'Test file 1'assertfiles['data/bucket-1']==NoneTheminio_volumecontainer will be created with an empty folder (bucket-1) and a text file calledexample.txtin a seperate folder calledbucket-2.The default scope for this factory isfunction. This means a new volume will be created for each test that is executed. The volume will be removed after the test using it has finished.Fixturesdocker_clientThedocker_clientfixture returns an instance of the official docker client.deftest_container_created(docker_client,fakedns):forcindocker_client.containers.list(ignore_removed=True):ifc.id==fakedns.id:# Looks like we managed to start one!breakelse:assertFalse,'Looks like we failed to start a container'Take care when using thedocker_clientdirectly:Obviously resources created imperatively via the API won't be removed at the end of the test automaticallyIt's easy to break xdist compatibilityAlways useignore_removedwithdocker_client.containers.list()- it is racy withoutIt's easy to find other instances of the resources you are working with (created in other workers). Be mindful of this!Don't take destructive action - someone could be running tests on a machine with other (non-test) containers running, collateral damage is easy and should be avoided.This is the fixture used by our fixture factories. This means if you define adocker_clientfixture of your own then the tests will use that instead.Tips and tricksTesting build artifactsWe often find ourselves using a set of tests against a container we've built at test time (withbuild()) but then wanting to use the same tests with an artifact generated on our CI platform (withimage()). This ended up looking like this:if not os.environ.get('IMAGE_ID', ''):
    image = build(path='examples/resolver-service/dns')
else:
    image = image(name=os.environ['IMAGE_ID'])But now you can just do:frompytest_docker_toolsimportimage_or_buildimage=image_or_build(environ_key='IMAGE_ID',path='examples/resolver-service/dns',)deftest_image(image):assertimage.attrs['Os']=='linux'Network differences between dev env and CIAnother common difference between your dev environment and your CI environment might be that your tests end up running in Docker on your CI. If you bind-mount yourdocker.sockthen your tests might end up running on the same container network as the containers you are testing, and unable to access any port you are mapping to the host box. In otherwords:On your dev machine your tests might access locahost:8000 to access your test instance (ports mapped to host)On your CI machine they might need to access 172.16.0.5:8000 to access your test instanceThe container object has aget_addrhelper which will return the right thing depending on the environment it is in.frompytest_docker_toolsimportcontainerapiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None})deftest_connect_my_service(apiserver):ip,port=apiserver.get_addr('8080/tcp')# ... connect to ip:port ...Dynamic scopeThe pytest fixture decorator now lets you specify a callback to determine the scope of a fixture This is calleddynamic scope. You can use this to make it a runtime option whether to have a container instance per test or per test run. For example:# conftest.pyfrompytest_docker_toolsimportcontainer,fetchdefdetermine_scope(fixture_name,config):ifconfig.getoption("--keep-containers",None):return"session"return"function"memcache_image=fetch(repository='memcached:latest')memcache=container(image='{memcache_image.id}',scope=determine_scope,ports={'11211/tcp':None,},)Your tests can look exactly the same as before:deftest_connect_my_service(memcache):ip,port=memcache.get_addr('11211/tcp')# ... connect to ip:port ...Client fixturesYou will probably want to create an API client for the service you are testing. Although we've already done this in the README, its worth calling it out. You can define a client fixture, have it depend on your docker containers, and then only have to reference the client from your tests.# conftest.pyfromhttp.clientimportHTTPConnectionimportpytestfrompytest_docker_toolsimportbuild,containerfakedns_image=build(path='examples/resolver-service/dns',)fakedns=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'127.0.0.1',})apiserver_image=build(path='examples/resolver-service/api',)apiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None,},dns=['{fakedns.ips.primary}'])@pytest.fixturedefapiclient(apiserver):port=apiserver.ports['8080/tcp'][0]returnHTTPConnection(f'localhost:{port}')And then reference it from your tests:# test_the_test_client.pyimportjsondeftest_api_server(apiclient):apiclient.request('GET','/')response=apiclient.getresponse()assertresponse.status==200result=json.loads(response.read())assertresult['result']=='127.0.0.1'In this example, any test that uses thehpfeeds_clientfixture will get a properly configure client connected to a broker running in a Docker container on an ephemeral high port. When the test finishes the client will cleanly disconnect, and the docker container will be thrown away.Fixture overloadingComplicated environments can be defined with fixture factories. They form a directed acyclic graph. By using fixture overloading it is possible to (in the context of a single test module) replace a node in that dependency graph without having to redefine the entire environment.Replacing a container fixture without having to redefine its dependentsYou can define a fixture in yourconftest.py:# conftest.pyfromhttp.clientimportHTTPConnectionimportpytestfrompytest_docker_toolsimportbuild,containerfakedns_image=build(path='examples/resolver-service/dns',)fakedns=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'127.0.0.1',})apiserver_image=build(path='examples/resolver-service/api',)apiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None,},dns=['{fakedns.ips.primary}'])@pytest.fixturedefapiclient(apiserver):port=apiserver.ports['8080/tcp'][0]returnHTTPConnection(f'localhost:{port}')You can then overload these fixtures in your test modules. For example, if redis had a magic replication feature and you want to test for an edge case with your API you could in yourtest_smoketest_alternate.py:# test_smoketest_alternate.pyimportjsonfrompytest_docker_toolsimportcontainerfakedns=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'192.168.192.168',})deftest_api_server(apiclient):apiclient.request('GET','/')response=apiclient.getresponse()assertresponse.status==200result=json.loads(response.read())assertresult['result']=='192.168.192.168'Here we have redefined the fakedns container locally intest_smoketest_alternate. It is able to use thefakedns_imagefixture we defined inconftest.py. More crucially though, intest_smoketest_alternate.pywhen we use the coreapiclientfixture it actually pulls in the local definition offakednsand not the one fromconftest.py! You don't have to redefine anything else. It just works.Injecting fixture configuration through fixturesYou can pull in normal py.test fixtures from your fixture factory too. This means we can use fixture overloading and pass in config. In yourconftest.py:# conftest.pyfromhttp.clientimportHTTPConnectionimportpytestfrompytest_docker_toolsimportbuild,containerfakedns_image=build(path='examples/resolver-service/dns',)fakedns=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'{example_com_a}',})apiserver_image=build(path='examples/resolver-service/api',)apiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None,},dns=['{fakedns.ips.primary}'])@pytest.fixturedefapiclient(apiserver):port=apiserver.ports['8080/tcp'][0]returnHTTPConnection(f'localhost:{port}')@pytest.fixturedefexample_com_a():return'127.0.0.1'When a test uses the apiclient fixture now they will get the fakedns container configured as normal. However you can redefine the fixture in your test module - and the other fixtures will still respect it. For example:# test_smoketest_alternate.pyimportjsonimportpytest@pytest.fixturedefexample_com_a():return'192.168.192.168'deftest_api_server(apiclient):apiclient.request('GET','/')response=apiclient.getresponse()assertresponse.status==200result=json.loads(response.read())assertresult['result']=='192.168.192.168'Yourapi_servercontainer (and itsredisbackend) will be built as normal, only in this one test module it will use its sqlite backend.Fixture parameterisationYou can create parameterisation fixtures. Perhaps you wan to run all yourapi_servertests against both of your authentication backends. Perhaps you have a fake that you want to test multiple configurations of.In yourconftest.py:# conftest.pyfromhttp.clientimportHTTPConnectionimportpytestfrompytest_docker_toolsimportbuild,containerfakedns_image=build(path='examples/resolver-service/dns',)fakedns_localhost=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'127.0.0.1',})fakedns_alternate=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'192.168.192.168',})@pytest.fixture(scope='function',params=['fakedns_localhost','fakedns_alternate'])deffakedns(request):returnrequest.getfixturevalue(request.param)apiserver_image=build(path='examples/resolver-service/api',)apiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None,},dns=['{fakedns.ips.primary}'])@pytest.fixturedefapiclient(apiserver):port=apiserver.ports['8080/tcp'][0]returnHTTPConnection(f'localhost:{port}')The test is the same as the first example, only now it will be tested against 2 different fake configurations.# test_smoketest.pyimportipaddressimportjsondeftest_api_server(apiclient):apiclient.request('GET','/')response=apiclient.getresponse()assertresponse.status==200result=json.loads(response.read())ipaddress.ip_address(result['result'])This test will be invoked twice - once against the memory backend, and once against the sqlite backend.Fixture wrappersYou can wrap your fixtures with awrapper_class. This allows you to add helper methods to fixtures for use in your tests. In the case of thecontainerfixture factory you can also implementready()to add additional container readyness checks.In previous tests we've created an entire test client fixture. Withwrapper_classwe could hang this convenience method off the fixture itself instead:# test_fixture_wrappers.pyimportipaddressimportjsonimportrandomfromhttp.clientimportHTTPConnectionimportpytestfrompytest_docker_toolsimportbuild,containerfrompytest_docker_toolsimportwrappersclassContainer(wrappers.Container):defready(self):# This is called until it returns True - its a great hook for e.g.# waiting until a log message appears or a pid file is created etcifsuper().ready():returnrandom.choice([True,False])returnFalsedefclient(self):port=self.ports['8080/tcp'][0]returnHTTPConnection(f'localhost:{port}')fakedns_image=build(path='examples/resolver-service/dns',)fakedns=container(image='{fakedns_image.id}',environment={'DNS_EXAMPLE_COM__A':'127.0.0.1',})apiserver_image=build(path='examples/resolver-service/api',)apiserver=container(image='{apiserver_image.id}',ports={'8080/tcp':None,},dns=['{fakedns.ips.primary}'],wrapper_class=Container,)deftest_container_wrapper_class(apiserver):client=apiserver.client()client.request('GET','/')response=client.getresponse()assertresponse.status==200result=json.loads(response.read())ipaddress.ip_address(result['result'])Referencing Non-String Returning FixturesYou can define resources by calling the provided factories with parameters:frompytest_docker_toolsimportcontainercache=container(name='my_cache_container',image='1838567e84867e8498695403067879',environment={'foo':'bar','mode':'prod'})As given in previous examples it's possible to resolve factory arguments dynamically by referencing fixtures in a string templates like manner  ('{<fixture_name>}'):frompytest_docker_toolsimportcontainer,fetchcache_image=fetch(repository='memcached:latest')cache=container(name='my_cache_container',image='{cache_image.id}',environment={'foo':'bar','mode':'prod'})In this example the image id will be obtained from the image wrapper object that is provided byfetch(). However this only allows to retrieve values that are string like. E.g. it's not possible to dynamically obtain a dictionary object for theenvinronmentargument by using the string template like syntax. Doing so would only result in a stringified dictionary.To obtain non string return value from a fixture there are two options. First you can define another fixture in the same file or import the fixture. Afterwards you need to reference it as follows:importpytestfrompytest_docker_toolsimportcontainer,fetch,fxtrcache_image=fetch(repository='memcached:latest')@pytest.fixture()defmemcached_env():yield{'foo':'bar','mode':'prod'}cache=container(name='my_cache_container',image='{cache_image.id}',environment=memcached_env)However normally working with fixtures in pytest does not require importing them in the first place. This is where thefxtrhelper function can be used:importpytestfrompytest_docker_toolsimportcontainer,fetch,fxtrcache_image=fetch(repository='memcached:latest')@pytest.fixture()defmemcached_env():yield{'foo':'bar','mode':'prod'}cache=container(name='my_cache_container',image='{cache_image.id}',environment=fxtr('memcached_env'))In both examples a proper dict object is handed over to the container function. For container resources this is useful to dynamically set environments or volumes based on fixtures.Reusable ContainersBy default, the container fixture factory of pytest-docker-tools will create every defined container when pytest is invoked, and clean them up before the session ends. This ensures that your test environment is clean and your tests aren't passing because of some tate left in the containers previously.Sometimes this behavior might not be what you want. When you are developing iteratively and running the tests over and over again the speed of your "test cycle" (how long it takes to fix a typo and re-run the tests) becomes important. When using the--reuse-containerscommand line argument pytest-docker-toolswon'tautomatically remove containers it has created. It will try to reuse them between pytest invocations. pytest-docker-tools will also keep track of if a container, volume or network has become stale (for example, if you change an image version) and automatically replace it.Attention: When using--reuse-containersyou must set thenameattribute on all your pytest-docker-tools fixtures. If you don't use--reuse-containerssetting thenameattribute is not required.Notes on using Reusable ContainersResources created using the--reuse-containersargument (containers, networks, volumes) will not have a finalizer, so scopes will may not behave like they normally would. It is up to the test author to make sure there are no collisions where 2 different fixtures share a name.When reusing resources you are responsible to clean them up (e.g. databases, volume data) as data written during tests will not be deleted when they are finished.Each Resource Container created by pytest-docker-tools will get the following label:creator: pytest-docker-tools. When required, this can be used to search for left over
resources. For example containers can be manually cleaned up by executingdocker ps -aq --filter "label=creator=pytest-docker-tools" | xargs docker rm -fHackingThis project usings poetry. You need a working poetry and python 3 environment before setting up a development environment forpytest-docker-tools. When you do you can just:poetry installTo run all the linters and tests run./scripts/test.shwithin poetry:poetry run ./scripts/tests.shThis will runpyupgrade,isortandblackwhich will modify your changes in place to match the code style we use.