DevOps

Cloud Infrastructure Testing: Testcontainers, Terratest, and Testing Your IaC After LocalStack Went Away

LocalStack ended its free tier in March 2026. Here is how to rebuild a real infrastructure testing practice with Testcontainers, Terratest, Moto, and Terraform's native test framework.

Infrastructure testing pipeline diagram showing Testcontainers and Terratest in a CI environment

I spent most of 2008 doing something I am not proud of: pushing Terraform to production and finding out if it worked by watching the deployment. We called it “testing in prod” with a straight face, as if that were a philosophy and not a confession of failure. The team was small, the stakes felt manageable, and the blast radius of a broken IAM policy or misconfigured security group was survivable. We learned what went wrong, we fixed it, and we moved on.

That approach does not scale. It never did. But for years the tooling to do something better did not really exist for infrastructure code the way it existed for application code. You could unit test your Lambda functions, you could integration test your APIs, but the Terraform module that wired all of it together? You pushed it and you hoped.

Then LocalStack arrived and for a while it felt like the answer. Local AWS emulation, good enough for most services, zero cost, open source. Teams built entire testing suites on it. CI pipelines ran against it. It became infrastructure.

And then in March 2026, LocalStack archived its public repository and moved core services behind a $39-per-month authentication wall, breaking every CI pipeline that ran docker pull localstack/localstack without a token. The community scrambled. Alternatives appeared overnight. The chaos was clarifying: teams that had built real testing practices weathered it. Teams that had built testing that depended on one tool had to rebuild from scratch.

This article is about building the kind of testing practice that survives that kind of disruption. Not just “how to replace LocalStack” but how to think about testing cloud infrastructure at every layer: unit, integration, and end-to-end. The tools matter less than the architecture. Pick the right layer for each concern and the specific tool becomes swappable.

The Testing Pyramid for Infrastructure

Application developers have the testing pyramid drilled into them. Unit tests at the base: fast, numerous, cheap. Integration tests in the middle: slower, fewer, more realistic. End-to-end tests at the top: slowest, fewest, closest to production.

Infrastructure testing needs the same mental model, and the industry spent years pretending it did not. “Just test in a staging environment” sounds reasonable until you realize your staging environment drifts from production, costs real money to keep running, and serializes your team because only one person can be changing state at a time.

The pyramid for infrastructure looks like this:

  • Unit tests: Validate that your Terraform modules, CloudFormation templates, or Helm charts have the right shape. No AWS calls. No Docker. Sub-second feedback.
  • Integration tests: Spin up emulated or real (ephemeral) AWS services and verify that your infrastructure code produces the right resources, wires them correctly, and that your application can actually talk to them.
  • End-to-end tests: Deploy to a real AWS account (ideally ephemeral, ideally per-branch) and run your acceptance tests against the real thing.

Most teams skip the bottom two layers and go straight to end-to-end. Then they wonder why their feedback loop is forty-five minutes long.

Infrastructure testing pyramid for cloud IaC, showing unit, integration, and end-to-end tiers

Unit Testing: Policy and Schema Validation

Before you reach for any testing framework, do the cheap things first.

Terraform has native validation built into the language. Use it. Variable validation blocks catch bad inputs before plan even runs:

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

Beyond language-level validation, three tools belong at this layer:

terraform validate runs locally and in CI with no external dependencies. It catches syntax errors, undefined references, and type mismatches. Takes under two seconds. There is no excuse for not running this in your pipeline.

Checkov (and its rivals OPA/Conftest, Regula) applies policy-as-code rules against your Terraform plan output or raw HCL. You can catch “no S3 bucket should have public ACLs” or “every EC2 instance needs a backup tag” before a single API call happens. We have the full policy story covered in our OPA and Kyverno guide, but Checkov’s Terraform-native rules library is worth knowing separately.

terraform plan with -out piped through jq is underused as a test. A planned change is a declarative spec of what Terraform intends to do. You can assert against it: “this plan should create exactly one RDS instance,” “this plan should not destroy any DynamoDB tables,” “this plan must not modify the production VPC.” The Terraform test framework (stable since 1.6) supports plan assertions natively.

The native test framework deserves attention. It lives in .tftest.hcl files alongside your modules and supports both plan mode (no real calls, just schema validation) and apply mode (real deployment against a real or emulated provider). The syntax is straightforward:

run "validate_s3_bucket_naming" {
  command = plan
  assert {
    condition     = aws_s3_bucket.artifacts.bucket == "my-company-artifacts-${var.environment}"
    error_message = "S3 bucket name does not follow naming convention."
  }
}

This runs in milliseconds in plan mode. No AWS account needed. No emulator running. This is what the bottom of your pyramid should look like.

Integration Testing Layer One: Moto for Python

If your infrastructure glue code is in Python (Lambda functions, CDK constructs, boto3 scripts), Moto is your best friend at the integration layer.

Moto patches boto3 calls in-process. No Docker. No network. No external service to start or stop. It intercepts the call at the boto3 layer, runs a pure-Python AWS implementation, and returns a response. Tests run at unit-test speed while exercising real AWS SDK behavior:

import boto3
from moto import mock_aws

@mock_aws
def test_lambda_creates_s3_object():
    s3 = boto3.client("s3", region_name="us-east-1")
    s3.create_bucket(Bucket="test-bucket")
    
    # call your Lambda handler directly
    from my_lambda import handler
    handler({"bucket": "test-bucket", "key": "result.json"}, {})
    
    response = s3.get_object(Bucket="test-bucket", Key="result.json")
    assert response["Body"].read() == b'{"status": "ok"}'

Moto supports over 300 AWS services. The coverage is not perfect, and edge cases around IAM policy evaluation are hit or miss, but for the vast majority of Lambda, S3, SQS, SNS, DynamoDB, and Secrets Manager interactions, it works reliably.

The limitation is that Moto only works from inside a Python test process. If you have a polyglot stack or you need to test the Terraform layer itself, you need something that can stand in for a real AWS endpoint.

Integration Testing Layer Two: The Post-LocalStack Landscape

When LocalStack archived its public images in March 2026, the community response was swift. Three alternatives emerged quickly enough to matter:

MiniStack is the most architecturally interesting. Rather than faking AWS responses in memory, MiniStack spins up actual containers for stateful services: real PostgreSQL for RDS, real Redis for ElastiCache, real Docker containers for ECS. The endpoint sits at port 4566, the same as LocalStack. Your boto3 clients, Terraform providers, and CDK stacks point there unchanged. The trade-off is startup time (containers take time to spin up) in exchange for behavior that matches real AWS much more closely.

Floci launched literally two days before the LocalStack shutdown, which either means the founders had inside information or they were watching the writing on the wall. It runs at port 4566, uses the same endpoint conventions, and covers the core services most teams actually use: S3, SQS, SNS, DynamoDB, Lambda, STS, and IAM. Coverage is narrower than LocalStack’s peak but growing. It stays free and open source.

Moto server mode is worth knowing. Moto can run as an HTTP server (python -m moto.server), exposing the same endpoint patterns that boto3 expects. You lose a small amount of fidelity compared to in-process mocking but gain the ability to test any language or tool that speaks the AWS SDK protocol. A Go binary making SQS calls. A Terraform provider. A shell script using the AWS CLI. All of them can point at Moto server mode.

My practical recommendation: use Moto in-process for Python unit tests. Use MiniStack or Floci for polyglot integration tests where you need a real endpoint. Do not depend on any single emulator as your only testing strategy.

Testcontainers: Real Services, Disposable Infrastructure

Testcontainers is different from the AWS emulation tools. Instead of faking AWS, it spins up real Docker containers for the services your application depends on: PostgreSQL, Redis, Kafka, Elasticsearch, RabbitMQ, whatever you need.

The magic is lifecycle management. Testcontainers starts containers before your tests, runs the tests, and tears everything down when done. Each test run gets fresh, isolated state. No shared staging database that someone left in a weird state. No Redis cache poisoning your test results from three days ago.

Testcontainers architecture diagram showing test isolation with ephemeral Docker containers per test suite

The Java and Python libraries are the most mature, but there are official SDKs for Go, Rust, .NET, Node.js, and Ruby. The pattern is consistent across languages:

from testcontainers.postgres import PostgresContainer

def test_user_repository():
    with PostgresContainer("postgres:16") as postgres:
        engine = create_engine(postgres.get_connection_url())
        # run migrations, seed data, test your repository layer
        repo = UserRepository(engine)
        user = repo.create(email="test@example.com")
        assert user.id is not None

What I particularly like about Testcontainers is that it forces you to be honest about your dependencies. If your code requires PostgreSQL 16 with the pgcrypto extension enabled, your test either sets that up or it fails. No more “works on my machine because I have a global Postgres running.” If your CI pipeline runs this and it passes, you know it actually works against the real database behavior.

For cloud-native applications, the LocalStack module for Testcontainers gives you a clean way to get a LocalStack instance as a container (if you have a license) or to swap in Floci or MiniStack at the container level. The point is the abstraction: your test code does not care which container image serves port 4566, which makes swapping emulators a one-line change.

This composability matters for CI/CD pipelines. Your pipeline does not install any services globally. It just runs tests. Testcontainers handles the rest.

Terratest: Testing IaC with Real Deployments

Terratest is a Go library from Gruntwork that treats infrastructure code the way application developers treat their APIs: write a test that deploys real infrastructure, makes real assertions, and tears it down. No mocking. No emulation. Real AWS, real resources, real validation.

I know what you are thinking: “that sounds expensive and slow.” You are right that it is slower than unit tests. It is not as slow as you think for most modules, and for the kind of validation it provides, there is no substitute.

The pattern is straightforward:

func TestS3ModuleCreatesEncryptedBucket(t *testing.T) {
    t.Parallel()
    
    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/s3-bucket",
        Vars: map[string]interface{}{
            "bucket_name": fmt.Sprintf("test-bucket-%s", random.UniqueId()),
            "environment": "test",
        },
    }
    
    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)
    
    bucketName := terraform.Output(t, terraformOptions, "bucket_name")
    
    // Assert real AWS state
    encryption := aws.GetS3BucketDefaultEncryption(t, "us-east-1", bucketName)
    assert.Equal(t, "aws:kms", encryption.ApplyServerSideEncryptionByDefault.SSEAlgorithm)
}

The defer-destroy pattern is important. Even if the test fails, Terraform destroys the resources. Combined with Terratest’s retry helpers (AWS is asynchronous, S3 bucket policies take time to propagate), you get tests that handle real-world timing without fragile sleeps.

Terratest integrates naturally with the Terraform state management workflow you already have. Tests use isolated state backends, typically with a unique ID in the key path. Each test run is fully independent. Teams running this in CI on pull requests use temporary AWS accounts or IAM role scoping to limit the blast radius.

For OpenTofu users, Terratest supports both. The terraform.Options struct accepts a binary path; point it at the tofu binary and everything works identically.

The critique of Terratest is valid: tests that hit real AWS take five to twenty minutes per module, and they cost real money. My view is that this cost is worth it for your core modules (VPC, EKS cluster, RDS) and unnecessary for simple resource wrappers. Not every module needs a Terratest suite. The ones with complex logic, IAM wiring, or cross-service dependencies absolutely do.

Kubernetes Testing: kind and kwok

If your infrastructure stack includes Kubernetes, you need a local cluster for integration testing. Two tools matter here.

kind (Kubernetes in Docker) runs a full Kubernetes cluster inside Docker containers. It supports multi-node clusters, specific Kubernetes versions, and custom configuration. A kind cluster boots in under two minutes on a modern laptop. For testing Helm charts, Kubernetes operators, admission webhooks, and anything that needs a real API server, kind is the standard.

kind create cluster --name test-cluster --config kind-config.yaml
kubectl apply -f your-manifests/
# run assertions
kind delete cluster --name test-cluster

kwok (Kubernetes WithOut Kubelet) is for testing at the control-plane layer. If you are building operators, testing schedulers, or validating admission webhooks at scale, kwok simulates thousands of nodes without actually running containers. This sounds niche until you are trying to test a scheduler configuration with 500 simulated nodes and realize kind cannot realistically do that on a laptop.

The distinction matters: use kind when you need workloads to actually run (your pods need to start, your services need to be reachable). Use kwok when you are testing control-plane behavior and the actual workload execution does not matter.

For teams moving databases to Kubernetes, our database on Kubernetes guide covers the operational considerations. From a testing perspective: if you are using Testcontainers for your integration tests, you already have database containers handled. Testcontainers and kind compose well together.

The Drift Problem and Continuous Testing

One thing infrastructure testing cannot catch by itself: drift. Infrastructure tests validate that your code produces the right resources when you run it. They do not validate that the resources in production still match what your code says they should.

We covered the full infrastructure drift detection story separately. The short version for testing purposes: terraform plan run on a schedule against your live environments is a form of continuous testing. If the plan shows a diff, something changed outside of your IaC workflow. Treat it like a failing test.

Some teams pipe scheduled terraform plan output through policy checks. If the plan shows “0 changes,” all clear. If it shows unauthorized changes, page someone. This is genuinely useful and costs almost nothing to set up.

The CI Integration Pattern That Works

After eight years of getting this wrong in various ways, here is the pipeline structure I would put in every cloud engineering team:

Pull Request:
  1. terraform validate (< 5 seconds)
  2. terraform fmt --check (< 2 seconds)
  3. checkov scan (< 30 seconds)
  4. terraform plan (1-5 minutes, comments plan to PR)
  5. unit tests with Moto (< 60 seconds)

Merge to main:
  6. Testcontainers integration tests (2-10 minutes)
  7. Terratest module tests for critical modules (10-20 minutes)

Nightly:
  8. terraform plan against staging/prod (drift detection)
  9. End-to-end tests in ephemeral environment

The first five steps give you fast feedback on every PR with zero cost. Steps six and seven add real confidence with manageable time investment. Steps eight and nine are your safety net.

CI/CD pipeline diagram showing infrastructure test stages from PR validation through nightly drift detection

The trap I see teams fall into is trying to push end-to-end tests earlier in the pipeline. The temptation is understandable, you want the most realistic tests running on every commit. But a twenty-minute feedback loop on every PR kills developer productivity and trains engineers to ignore CI. Fast tests first, slow tests gated on merge.

A Real Failure I Found with This Approach

Here is the war story version. In 2019, we had a Terraform module for our EKS cluster that we were reasonably proud of. It had been running in production for eight months without incident.

We added a Terratest suite covering the module as part of a refactor. In the first run, the test failed on the IAM assertion. The IAM role our node groups used had an inline policy that granted ec2:* on *. The person who wrote the module had meant to grant ec2:DescribeInstances on the cluster’s autoscaling group. The wildcard policy had been running in production for eight months, attached to every node in our EKS cluster, giving every pod that could assume that role essentially full EC2 access.

We found it because a test forced us to actually assert what IAM roles the module created. The drift detection would eventually have caught that we created it, but it would not have told us the policy was wrong. Only a test that said “this role should have exactly these permissions” could catch that.

That is why infrastructure testing exists. Not to catch syntax errors. To catch the gap between what you intended and what you built.

Practical Migration from LocalStack

If you have existing LocalStack-dependent pipelines and need to migrate, the path is less painful than it looks:

  1. Audit which services you actually use. Most pipelines use five to ten services even if they have twenty in their codebase. Focus on those.
  2. Python-heavy stack? Migrate to Moto in-process. It is faster anyway.
  3. Polyglot or Terraform-testing stack? Try MiniStack first if you need behavioral fidelity, Floci if you need breadth.
  4. For the services where emulation is genuinely poor (complex IAM policy evaluation, some newer services), write your tests to run against real AWS in a test account with a narrow-scope IAM role. The cost is low, the fidelity is perfect.

The infrastructure as code tools guide covers the IaC landscape; the testing layer described here works with Terraform, OpenTofu, Pulumi, and CDK interchangeably. The framework does not matter. The discipline does.

What Testing Cannot Do

I want to be honest about the limits here. A comprehensive infrastructure testing practice will not save you from:

  • Business logic bugs in your architecture decisions. Tests validate that you built what you designed. They do not validate that you designed the right thing.
  • AWS service behavior changes. When AWS changes how an eventually-consistent operation behaves, your tests will not catch it until your test environment gets the new behavior.
  • Terraform provider bugs. These exist. They are rare. When they happen, your tests will fail for reasons that have nothing to do with your code.
  • Quota and rate limiting issues at scale. Terratest tests a module once. Production runs it a thousand times. Throttling behavior does not show up in tests.

These limits do not argue against testing. They argue for keeping your testing practice grounded. Test the decisions you can control. Accept that some things only emerge at production scale. Build your observability stack accordingly.

Where to Start If You Have Nothing

If your team currently has zero infrastructure tests, do not try to build the full pyramid overnight. Pick one thing:

Add terraform validate and checkov to your CI pipeline today. This costs thirty minutes to set up and catches a real class of problems with zero ongoing maintenance.

Next sprint: add Moto tests for your most critical Lambda functions, the ones that touch money, the ones that touch IAM, the ones where a bug costs real users something. Twenty tests that cover your highest-risk functions are worth more than two hundred tests on CRUD endpoints.

The month after: pick your most complex Terraform module, the one that always has something weird happen when you update it, and write a Terratest for it. Run it against a real AWS account. Watch it catch the thing you did not know was wrong.

After twenty years of building cloud infrastructure, the single biggest quality improvement I have seen teams make is not adopting a new tool. It is deciding that “push and pray” is not a deployment strategy and building the feedback loops that make something better possible.

The tooling to do this properly now exists. LocalStack’s retreat behind a paywall was a disruption but not a disaster. The ecosystem that replaced it is more diverse and in some ways more capable. The real question was never “which AWS emulator should I use.” It was “do we treat our infrastructure code with the same rigor we treat our application code?”

The answer, in 2026 with the tools available, should be yes.