Repository: getmoto/moto
Pull request #8991 (2025-06-16 21:21:27)

elasticache: create and describe subnetgroup
Addresses part of https://github.com/getmoto/moto/issues/8932.

This PR introduces two new functions to the elasticache resource:
1. create_cache_subnet_group
2. describe_cache_subnet_groups
3. list_tags_for_resource

Added tests for:
- when there are actual mock subnets referenced in `test_create_cache_subnet_group_and_mock_vpc()`
- and when the subnetIds referenced need default values


## Note
Regarding the missing codecov tests, I left one of the subnet tests commented out because the current `create_subnet` call hard-codes `ipv6Native` parameter to `False` and does not support ipv6 by itself without ipv4 cidr. Since subnet is used by many other moto resources, I decided I should not try updating the ipv6native support in ec2 subnet in the same PR without thorough testing. Let me know if you would like me to remove the commented out `test_cache_subnet_group_with_ipv6_native_subnets` test altogether.

## References
- https://github.com/getmoto/moto/blob/eba33cf1550a7a40a053ca060d95fdd3acd44e68/moto/rds/models.py#L2000C9-L2003C39 - For how `ec2_backend` was used to `describe_subnets`

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8991?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
Attention: Patch coverage is `83.67347%` with `16 lines` in your changes missing coverage. Please review.
> Project coverage is 92.84%. Comparing base [(`fbe9f87`)](https://app.codecov.io/gh/getmoto/moto/commit/fbe9f877805aba1660c4414257dcd8fc2b4be740?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`c5249ed`)](https://app.codecov.io/gh/getmoto/moto/commit/c5249ed0490e9faed19083ecb056cc5c24bbe8ee?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).
> Report is 2 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/getmoto/moto/pull/8991?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Patch % | Lines |
|---|---|---|
| [moto/elasticache/models.py](https://app.codecov.io/gh/getmoto/moto/pull/8991?src=pr&el=tree&filepath=moto%2Felasticache%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9lbGFzdGljYWNoZS9tb2RlbHMucHk=) | 81.15% | [13 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/8991?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) |
| [moto/elasticache/exceptions.py](https://app.codecov.io/gh/getmoto/moto/pull/8991?src=pr&el=tree&filepath=moto%2Felasticache%2Fexceptions.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9lbGFzdGljYWNoZS9leGNlcHRpb25zLnB5) | 75.00% | [3 Missing :warning: ](https://app.codecov.io/gh/getmoto/moto/pull/8991?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) |

:x: Your patch check has failed because the patch coverage (83.67%) is below the target coverage (90.00%). You can increase the patch coverage or adjust the [target](https://docs.codecov.com/docs/commit-status#target) coverage.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #8991      +/-   ##
==========================================
- Coverage   92.85%   92.84%   -0.01%     
==========================================
  Files        1287     1287              
  Lines      112758   112856      +98     
==========================================
+ Hits       104700   104782      +82     
- Misses       8058     8074      +16     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8991/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/8991/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.50% <21.42%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8991/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `92.82% <83.67%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/8991?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
algobarb: Thank you for taking the time to review the PR. I have made the requested changes. @bblommers 

Patch:
diff --git a/moto/elasticache/exceptions.py b/moto/elasticache/exceptions.py
index 0266a151f..9fba9597a 100644
--- a/moto/elasticache/exceptions.py
+++ b/moto/elasticache/exceptions.py
@@ -81,11 +81,9 @@ class CacheClusterAlreadyExists(ElastiCacheException):
     code = 404
 
     def __init__(self, cache_cluster_id: str):
-        (
-            super().__init__(
-                "CacheClusterAlreadyExists",
-                message=f"Cache cluster {cache_cluster_id} already exists.",
-            ),
+        super().__init__(
+            "CacheClusterAlreadyExists",
+            message=f"Cache cluster {cache_cluster_id} already exists.",
         )
 
 
@@ -99,6 +97,26 @@ class CacheClusterNotFound(ElastiCacheException):
         )
 
 
+class CacheSubnetGroupAlreadyExists(ElastiCacheException):
+    code = 404
+
+    def __init__(self, cache_subnet_group_name: str):
+        super().__init__(
+            "CacheSubnetGroupAlreadyExists",
+            message=f"CacheSubnetGroup {cache_subnet_group_name} already exists.",
+        )
+
+
+class CacheSubnetGroupNotFound(ElastiCacheException):
+    code = 404
+
+    def __init__(self, cache_subnet_group_name: str):
+        super().__init__(
+            "CacheSubnetGroupNotFound",
+            message=f"CacheSubnetGroup {cache_subnet_group_name} not found.",
+        )
+
+
 class InvalidARNFault(ElastiCacheException):
     code = 400
 
@@ -107,3 +125,13 @@ class InvalidARNFault(ElastiCacheException):
             "InvalidARNFault",
             message=f"ARN {arn} is invalid.",
         )
+
+
+class InvalidSubnet(ElastiCacheException):
+    code = 404
+
+    def __init__(self, subnet_id: str):
+        super().__init__(
+            "InvalidSubnet",
+            message=f"Subnet {subnet_id} is invalid.",
+        )
diff --git a/moto/elasticache/models.py b/moto/elasticache/models.py
index 6861d1bdd..c55fb0803 100644
--- a/moto/elasticache/models.py
+++ b/moto/elasticache/models.py
@@ -11,9 +11,12 @@ from ..moto_api._internal import mock_random
 from .exceptions import (
     CacheClusterAlreadyExists,
     CacheClusterNotFound,
+    CacheSubnetGroupAlreadyExists,
+    CacheSubnetGroupNotFound,
     InvalidARNFault,
     InvalidParameterCombinationException,
     InvalidParameterValueException,
+    InvalidSubnet,
     UserAlreadyExists,
     UserNotFound,
 )
@@ -137,13 +140,93 @@ class CacheCluster(BaseModel):
         return self.tags
 
 
+class CacheSubnetGroup(BaseModel):
+    def __init__(
+        self,
+        account_id: str,
+        region_name: str,
+        cache_subnet_group_name: str,
+        cache_subnet_group_description: str,
+        subnet_ids: List[str],
+        tags: Optional[List[Dict[str, str]]],
+    ):
+        if tags is None:
+            tags = []
+        self.cache_subnet_group_name = cache_subnet_group_name
+        self.cache_subnet_group_description = cache_subnet_group_description
+        self.subnet_ids = subnet_ids
+        self.tags = tags
+
+        # Only import ec2_backends if necessary
+        from moto.ec2.models import ec2_backends
+
+        ec2_backend = ec2_backends[account_id][region_name]
+        self.supported_network_types = []
+        self.subnets_responses = []
+        vpc_exists = False
+        try:
+            # Get VPC details from provided subnet IDs
+            subnets = ec2_backend.describe_subnets(subnet_ids=subnet_ids)
+            vpc_exists = True
+        except Exception as e:
+            # Should raise InvalidSubnet if subnet_ids are invalid
+            if "InvalidSubnet" in str(e):
+                for subnet_id in subnet_ids:
+                    subnet_response: Dict[str, Any] = {}
+                    subnet_response["subnet_id"] = subnet_id
+                    subnet_response["subnet_az"] = {"Name": "us-east-1a"}
+                    subnet_response["subnet_supported_network_types"] = ["ipv4"]
+                    self.subnets_responses.append(subnet_response)
+                vpcs = ["vpc-0123456789abcdef0"]
+                self.supported_network_types = ["ipv4"]
+
+        if vpc_exists:
+            vpcs = []
+            for subnet in subnets:
+                subnet_response = {}
+                vpcs.append(subnet.vpc_id)
+                subnet_response["subnet_id"] = subnet.id
+                subnet_response["subnet_az"] = subnet.availability_zone
+                subnet_response["subnet_supported_network_types"] = []
+                if subnet.vpc_id != vpcs[0]:
+                    raise InvalidSubnet(subnet_id=subnet.id)
+
+                # ipv6 native subnets only appends ipv6
+                # You can't mix ipv6 native subnets with other types of subnets
+                if subnet.ipv6_native:
+                    self.supported_network_types.append("ipv6")
+                    subnet_response["subnet_supported_network_types"].append("ipv6")
+
+                # ipv4 only and dual_stack subnets both append ipv4
+                elif subnet.cidr_block:
+                    self.supported_network_types.append("ipv4")
+                    subnet_response["subnet_supported_network_types"].append("ipv4")
+
+                if subnet.ipv6_cidr_block_associations and subnet.cidr_block:
+                    self.supported_network_types.append("dual_stack")
+                    subnet_response["subnet_supported_network_types"].append(
+                        "dual_stack"
+                    )
+
+                self.subnets_responses.append(subnet_response)
+
+            if self.supported_network_types:
+                self.supported_network_types = list(set(self.supported_network_types))
+
+        self.arn = f"arn:{get_partition(region_name)}:elasticache:{region_name}:{account_id}:subnetgroup:{cache_subnet_group_name}"
+        self.vpc_id = vpcs[0] if vpcs else None
+
+    def get_tags(self) -> List[Dict[str, str]]:
+        return self.tags
+
+
 class ElastiCacheBackend(BaseBackend):
     """Implementation of ElastiCache APIs."""
 
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
         self.arn_regex = re_compile(
-            r"^arn:aws:elasticache:.*:[0-9]*:(cluster|snapshot):.*$"
+            r"^arn:aws:elasticache:.*:[0-9]*:(cluster|snapshot|subnetgroup):.*$"
         )
         self.users = dict()
         self.users["default"] = User(
@@ -157,6 +240,7 @@ class ElastiCacheBackend(BaseBackend):
         )
 
         self.cache_clusters: Dict[str, Any] = dict()
+        self.cache_subnet_groups: Dict[str, CacheSubnetGroup] = dict()
 
     def create_user(
         self,
@@ -334,6 +418,41 @@ class ElastiCacheBackend(BaseBackend):
                 return cache_cluster
         raise CacheClusterNotFound(cache_cluster_id)
 
+    def create_cache_subnet_group(
+        self,
+        cache_subnet_group_name: str,
+        cache_subnet_group_description: str,
+        subnet_ids: List[str],
+        tags: Optional[List[Dict[str, str]]],
+    ) -> CacheSubnetGroup:
+        if cache_subnet_group_name in self.cache_subnet_groups:
+            raise CacheSubnetGroupAlreadyExists(cache_subnet_group_name)
+
+        cache_subnet_group = CacheSubnetGroup(
+            account_id=self.account_id,
+            region_name=self.region_name,
+            cache_subnet_group_name=cache_subnet_group_name,
+            cache_subnet_group_description=cache_subnet_group_description,
+            subnet_ids=subnet_ids,
+            tags=tags,
+        )
+        self.cache_subnet_groups[cache_subnet_group_name] = cache_subnet_group
+        return cache_subnet_group
+
+    @paginate(PAGINATION_MODEL)
+    def describe_cache_subnet_groups(
+        self,
+        cache_subnet_group_name: str,
+    ) -> List[CacheSubnetGroup]:
+        if cache_subnet_group_name:
+            if cache_subnet_group_name in self.cache_subnet_groups:
+                cache_subnet_group = self.cache_subnet_groups[cache_subnet_group_name]
+                return list([cache_subnet_group])
+            else:
+                raise CacheSubnetGroupNotFound(cache_subnet_group_name)
+        cache_subnet_groups = list(self.cache_subnet_groups.values())
+        return cache_subnet_groups
+
     def list_tags_for_resource(self, arn: str) -> List[Dict[str, str]]:
         if self.arn_regex.match(arn):
             arn_breakdown = arn.split(":")
@@ -342,6 +461,9 @@ class ElastiCacheBackend(BaseBackend):
             if resource_type == "cluster":
                 if resource_name in self.cache_clusters:
                     return self.cache_clusters[resource_name].get_tags()
+            elif resource_type == "subnetgroup":
+                if resource_name in self.cache_subnet_groups:
+                    return self.cache_subnet_groups[resource_name].get_tags()
             else:
                 return []
         else:
diff --git a/moto/elasticache/responses.py b/moto/elasticache/responses.py
index 7576914e1..720a56bd9 100644
--- a/moto/elasticache/responses.py
+++ b/moto/elasticache/responses.py
@@ -191,6 +191,34 @@ class ElastiCacheResponse(BaseResponse):
         tags = self.elasticache_backend.list_tags_for_resource(arn)
         return template.render(tags=tags)
 
+    def create_cache_subnet_group(self) -> str:
+        cache_subnet_group_name = self._get_param("CacheSubnetGroupName")
+        cache_subnet_group_description = self._get_param("CacheSubnetGroupDescription")
+        subnet_ids = self._get_multi_param_dict("SubnetIds").get("SubnetIdentifier", [])
+        tags = (self._get_multi_param_dict("Tags") or {}).get("Tag", [])
+        cache_subnet_group = self.elasticache_backend.create_cache_subnet_group(
+            cache_subnet_group_name=cache_subnet_group_name,
+            cache_subnet_group_description=cache_subnet_group_description,
+            subnet_ids=subnet_ids,
+            tags=tags,
+        )
+        template = self.response_template(CREATE_CACHE_SUBNET_GROUP_TEMPLATE)
+        return template.render(cache_subnet_group=cache_subnet_group)
+
+    def describe_cache_subnet_groups(self) -> str:
+        cache_subnet_group_name = self._get_param("CacheSubnetGroupName")
+        max_records = self._get_param("MaxRecords")
+        marker = self._get_param("Marker")
+        cache_subnet_groups, marker = (
+            self.elasticache_backend.describe_cache_subnet_groups(
+                cache_subnet_group_name=cache_subnet_group_name,
+                marker=marker,
+                max_records=max_records,
+            )
+        )
+        template = self.response_template(DESCRIBE_CACHE_SUBNET_GROUPS_TEMPLATE)
+        return template.render(marker=marker, cache_subnet_groups=cache_subnet_groups)
+
 
 USER_TEMPLATE = """<UserId>{{ user.id }}</UserId>
     <UserName>{{ user.name }}</UserName>
@@ -610,3 +638,76 @@ LIST_TAGS_FOR_RESOURCE_TEMPLATE = """<ListTagsForResourceResponse xmlns="http://
     <RequestId>8c21ba39-a598-11e4-b688-194eaf8658fa</RequestId>
   </ResponseMetadata>
 </ListTagsForResourceResponse>"""
+
+CREATE_CACHE_SUBNET_GROUP_TEMPLATE = """<CreateCacheSubnetGroupResponse xmlns="http://elasticache.amazonaws.com/doc/2015-02-02/">
+  <ResponseMetadata>
+    <RequestId>1549581b-12b7-11e3-895e-1334aEXAMPLE</RequestId>
+  </ResponseMetadata>
+  <CreateCacheSubnetGroupResult>
+    <CacheSubnetGroup>
+      <CacheSubnetGroupName>{{ cache_subnet_group.cache_subnet_group_name }}</CacheSubnetGroupName>
+      <CacheSubnetGroupDescription>{{ cache_subnet_group.cache_subnet_group_description }}</CacheSubnetGroupDescription>
+      <VpcId>{{ cache_subnet_group.vpc_id }}</VpcId>
+      <Subnets>
+        {% for subnet in cache_subnet_group.subnets_responses %}
+        <Subnet>
+          <SubnetIdentifier>{{ subnet.subnet_id }}</SubnetIdentifier>
+          <SubnetAvailabilityZone>
+            <Name>{{ subnet.subnet_az }}</Name>
+          </SubnetAvailabilityZone>
+          <SupportedNetworkTypes>
+            {% for network_type in subnet.subnet_supported_network_types %}
+              <member>{{ network_type }}</member>
+            {% endfor %}
+          </SupportedNetworkTypes>
+        </Subnet>
+        {% endfor %}
+      </Subnets>
+      <ARN>{{ cache_subnet_group.arn }}</ARN>
+      <SupportedNetworkTypes>
+        {% for network_type in cache_subnet_group.supported_network_types %}
+          <member>{{ network_type }}</member>
+        {% endfor %}
+      </SupportedNetworkTypes>
+    </CacheSubnetGroup>
+  </CreateCacheSubnetGroupResult>
+</CreateCacheSubnetGroupResponse>"""
+
+DESCRIBE_CACHE_SUBNET_GROUPS_TEMPLATE = """<DescribeCacheSubnetGroupsResponse xmlns="http://elasticache.amazonaws.com/doc/2015-02-02/">
+  <ResponseMetadata>
+    <RequestId>1549581b-12b7-11e3-895e-1334aEXAMPLE</RequestId>
+  </ResponseMetadata>
+  <DescribeCacheSubnetGroupsResult>
+    {% if marker %}<Marker>{{ marker }}</Marker>{% endif %}
+    <CacheSubnetGroups>
+      {% for cache_subnet_group in cache_subnet_groups %}
+      <member>
+        <CacheSubnetGroupName>{{ cache_subnet_group.cache_subnet_group_name }}</CacheSubnetGroupName>
+        <CacheSubnetGroupDescription>{{ cache_subnet_group.cache_subnet_group_description }}</CacheSubnetGroupDescription>
+        <VpcId>{{ cache_subnet_group.vpc_id }}</VpcId>
+        <Subnets>
+          {% for subnet in cache_subnet_group.subnets_responses %}
+          <Subnet>
+            <SubnetIdentifier>{{ subnet.subnet_id }}</SubnetIdentifier>
+            <SubnetAvailabilityZone>
+              <Name>{{ subnet.subnet_az }}</Name>
+            </SubnetAvailabilityZone>
+            <SupportedNetworkTypes>
+              {% for network_type in subnet.subnet_supported_network_types %}
+              <member>{{ network_type }}</member>
+              {% endfor %}
+            </SupportedNetworkTypes>
+          </Subnet>
+          {% endfor %}
+        </Subnets>
+        <ARN>{{ cache_subnet_group.arn }}</ARN>
+        <SupportedNetworkTypes>
+          {% for network_type in cache_subnet_group.supported_network_types %}
+          <member>{{ network_type }}</member>
+          {% endfor %}
+        </SupportedNetworkTypes>
+      </member>
+      {% endfor %}
+    </CacheSubnetGroups>
+  </DescribeCacheSubnetGroupsResult>
+</DescribeCacheSubnetGroupsResponse>"""
diff --git a/moto/elasticache/utils.py b/moto/elasticache/utils.py
index e006361cd..ac93e8975 100644
--- a/moto/elasticache/utils.py
+++ b/moto/elasticache/utils.py
@@ -7,6 +7,12 @@ PAGINATION_MODEL = {
         "limit_default": 100,
         "unique_attribute": "cache_cluster_id",
     },
+    "describe_cache_subnet_groups": {
+        "input_token": "marker",
+        "limit_key": "max_records",
+        "limit_default": 100,
+        "unique_attribute": "cache_subnet_group_name",
+    },
 }
 VALID_AUTH_MODE_KEYS = ["Type", "Passwords"]
 VALID_ENGINE_TYPES = ["redis", "valkey"]


Test patch:
diff --git a/tests/test_elasticache/test_elasticache.py b/tests/test_elasticache/test_elasticache.py
index 48cd7ff94..a048c2692 100644
--- a/tests/test_elasticache/test_elasticache.py
+++ b/tests/test_elasticache/test_elasticache.py
@@ -1,3 +1,5 @@
+from unittest import SkipTest
+
 import boto3
 import pytest
 from botocore.exceptions import ClientError
@@ -689,3 +691,287 @@ def test_list_tags_cache_cluster():
         {"Value": "bar", "Key": "foo"},
         {"Value": "bar1", "Key": "foo1"},
     ]
+
+
+@mock_aws
+def test_create_cache_subnet_group():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    resp = client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=["subnet-0123456789abcdef0", "subnet-abcdef0123456789"],
+        Tags=[
+            {"Key": "foo", "Value": "bar"},
+            {"Key": "foo1", "Value": "bar1"},
+        ],
+    )
+
+    assert (
+        resp["CacheSubnetGroup"]["ARN"]
+        == f"arn:aws:elasticache:us-east-2:{ACCOUNT_ID}:subnetgroup:test-subnet-group"
+    )
+    assert resp["CacheSubnetGroup"]["CacheSubnetGroupName"] == "test-subnet-group"
+    assert (
+        resp["CacheSubnetGroup"]["CacheSubnetGroupDescription"] == "Test subnet group"
+    )
+    assert len(resp["CacheSubnetGroup"]["Subnets"]) == 2
+
+    assert resp["CacheSubnetGroup"]["Subnets"][0]["SupportedNetworkTypes"] == ["ipv4"]
+    assert resp["CacheSubnetGroup"]["Subnets"][1]["SupportedNetworkTypes"] == ["ipv4"]
+    assert resp["CacheSubnetGroup"]["SupportedNetworkTypes"] == ["ipv4"]
+
+
+@mock_aws
+def test_create_cache_subnet_group_and_mock_vpc():
+    client = boto3.client("elasticache", region_name="us-east-2")
+
+    # Create a subnet group with two subnets
+    ec2_client = boto3.client("ec2", region_name="us-east-2")
+    vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16").get("Vpc")
+    vpc_id = vpc.get("VpcId")
+    subnet_1 = ec2_client.create_subnet(VpcId=vpc_id, CidrBlock="10.0.0.0/24").get(
+        "Subnet"
+    )
+    subnet_2 = ec2_client.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24").get(
+        "Subnet"
+    )
+
+    resp = client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=[subnet_1.get("SubnetId"), subnet_2.get("SubnetId")],
+        Tags=[
+            {"Key": "foo", "Value": "bar"},
+            {"Key": "foo1", "Value": "bar1"},
+        ],
+    )
+
+    assert (
+        resp["CacheSubnetGroup"]["ARN"]
+        == f"arn:aws:elasticache:us-east-2:{ACCOUNT_ID}:subnetgroup:test-subnet-group"
+    )
+    assert resp["CacheSubnetGroup"]["CacheSubnetGroupName"] == "test-subnet-group"
+    assert (
+        resp["CacheSubnetGroup"]["CacheSubnetGroupDescription"] == "Test subnet group"
+    )
+    assert len(resp["CacheSubnetGroup"]["Subnets"]) == 2
+    assert resp["CacheSubnetGroup"]["Subnets"][0]["SupportedNetworkTypes"] == ["ipv4"]
+    assert resp["CacheSubnetGroup"]["Subnets"][1]["SupportedNetworkTypes"] == ["ipv4"]
+    assert resp["CacheSubnetGroup"]["SupportedNetworkTypes"] == ["ipv4"]
+
+
+@mock_aws
+def test_describe_cache_subnet_groups():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=["subnet-0123456789abcdef0", "subnet-abcdef0123456789"],
+    )
+    resp = client.describe_cache_subnet_groups()
+    assert len(resp["CacheSubnetGroups"]) == 1
+    assert resp["CacheSubnetGroups"][0]["CacheSubnetGroupName"] == "test-subnet-group"
+    assert len(resp["CacheSubnetGroups"][0]["Subnets"]) == 2
+
+
+@mock_aws
+def test_describe_cache_subnet_group_specific():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=["subnet-0123456789abcdef0", "subnet-abcdef0123456789"],
+    )
+    resp = client.describe_cache_subnet_groups(CacheSubnetGroupName="test-subnet-group")
+    assert len(resp["CacheSubnetGroups"]) == 1
+    assert resp["CacheSubnetGroups"][0]["CacheSubnetGroupName"] == "test-subnet-group"
+    assert len(resp["CacheSubnetGroups"][0]["Subnets"]) == 2
+    assert resp["CacheSubnetGroups"][0]["SupportedNetworkTypes"] == ["ipv4"]
+
+
+@mock_aws
+def test_describe_cache_subnet_group_not_found():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    cache_subnet_group_unknown = "subnet_cluster_id_unknown"
+
+    with pytest.raises(ClientError) as exc:
+        client.describe_cache_subnet_groups(
+            CacheSubnetGroupName=cache_subnet_group_unknown,
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "CacheSubnetGroupNotFound"
+    assert err["Message"] == f"CacheSubnetGroup {cache_subnet_group_unknown} not found."
+
+
+@mock_aws
+def test_subnet_groups_list_tags():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=["subnet-0123456789abcdef0", "subnet-abcdef0123456789"],
+        Tags=[
+            {"Key": "foo", "Value": "bar"},
+            {"Key": "foo1", "Value": "bar1"},
+        ],
+    )
+
+    resp = client.list_tags_for_resource(
+        ResourceName=f"arn:aws:elasticache:us-east-2:{ACCOUNT_ID}:subnetgroup:test-subnet-group"
+    )
+    assert len(resp["TagList"]) == 2
+    assert resp["TagList"][0]["Key"] == "foo"
+    assert resp["TagList"][0]["Value"] == "bar"
+    assert resp["TagList"][1]["Key"] == "foo1"
+    assert resp["TagList"][1]["Value"] == "bar1"
+
+    client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group-no-tags",
+        CacheSubnetGroupDescription="Test subnet group no tags",
+        SubnetIds=["subnet-0123456789abcdef0", "subnet-abcdef0123456789"],
+    )
+
+    resp = client.list_tags_for_resource(
+        ResourceName=f"arn:aws:elasticache:us-east-2:{ACCOUNT_ID}:subnetgroup:test-subnet-group-no-tags"
+    )
+    assert len(resp["TagList"]) == 0
+
+
+@mock_aws
+def test_cache_subnet_group_already_exists():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=["subnet-0123456789abcdef0", "subnet-abcdef0123456789"],
+    )
+
+    with pytest.raises(ClientError) as exc:
+        client.create_cache_subnet_group(
+            CacheSubnetGroupName="test-subnet-group",
+            CacheSubnetGroupDescription="Test subnet group",
+            SubnetIds=["subnet-0123456789abcdef0", "subnet-abcdef0123456789"],
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "CacheSubnetGroupAlreadyExists"
+    assert err["Message"] == "CacheSubnetGroup test-subnet-group already exists."
+
+
+@mock_aws
+def test_cache_subnet_group_with_invalid_subnet_ids():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    ec2_client = boto3.client("ec2", region_name="us-east-2")
+    vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16").get("Vpc")
+    vpc2 = ec2_client.create_vpc(CidrBlock="10.1.0.0/16").get("Vpc")
+    subnet1 = ec2_client.create_subnet(VpcId=vpc["VpcId"], CidrBlock="10.0.0.0/24").get(
+        "Subnet"
+    )
+    subnet2 = ec2_client.create_subnet(
+        VpcId=vpc2["VpcId"], CidrBlock="10.1.0.0/24"
+    ).get("Subnet")
+
+    with pytest.raises(ClientError) as exc:
+        client.create_cache_subnet_group(
+            CacheSubnetGroupName="test-subnet-group",
+            CacheSubnetGroupDescription="Test subnet group",
+            SubnetIds=[subnet1["SubnetId"], subnet2["SubnetId"]],
+            Tags=[
+                {"Key": "foo", "Value": "bar"},
+                {"Key": "foo1", "Value": "bar1"},
+            ],
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidSubnet"
+
+
+@mock_aws
+def test_cache_subnet_group_with_ipv4_and_ipv6_subnets():
+    client = boto3.client("elasticache", region_name="us-east-2")
+    ec2_client = boto3.client("ec2", region_name="us-east-2")
+
+    vpc = ec2_client.create_vpc(
+        CidrBlock="10.0.0.0/16", Ipv6CidrBlock="2600:1f16:1cb1:6b00::/56"
+    ).get("Vpc")
+
+    vpc_id = vpc.get("VpcId")
+
+    subnet_dual = ec2_client.create_subnet(VpcId=vpc_id, CidrBlock="10.0.0.0/24").get(
+        "Subnet"
+    )
+    subnet_dual_id = subnet_dual.get("SubnetId")
+
+    ec2_client.associate_subnet_cidr_block(
+        SubnetId=subnet_dual_id, Ipv6CidrBlock="2600:1f16:1cb1:6b00::/60"
+    )
+
+    subnet_ipv4 = ec2_client.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24").get(
+        "Subnet"
+    )
+    subnet_ipv4_id = subnet_ipv4.get("SubnetId")
+
+    resp = client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=[subnet_dual_id, subnet_ipv4_id],
+        Tags=[
+            {"Key": "foo", "Value": "bar"},
+            {"Key": "foo1", "Value": "bar1"},
+        ],
+    )
+
+    if resp["CacheSubnetGroup"]["Subnets"][0]["SubnetIdentifier"] == subnet_dual_id:
+        assert resp["CacheSubnetGroup"]["Subnets"][0]["SupportedNetworkTypes"] == [
+            "ipv4",
+            "dual_stack",
+        ]
+        assert resp["CacheSubnetGroup"]["Subnets"][1]["SupportedNetworkTypes"] == [
+            "ipv4"
+        ]
+    else:
+        assert resp["CacheSubnetGroup"]["Subnets"][0]["SupportedNetworkTypes"] == [
+            "ipv4"
+        ]
+        assert resp["CacheSubnetGroup"]["Subnets"][1]["SupportedNetworkTypes"] == [
+            "ipv4",
+            "dual_stack",
+        ]
+    assert len(resp["CacheSubnetGroup"]["SupportedNetworkTypes"]) == 2
+    assert "ipv4" in resp["CacheSubnetGroup"]["SupportedNetworkTypes"]
+    assert "dual_stack" in resp["CacheSubnetGroup"]["SupportedNetworkTypes"]
+
+
+# The following test will be skipped until the create_subnet moto feature
+# adds support for the Ipv6Native parameter.
+@mock_aws
+def test_cache_subnet_group_with_ipv6_native_subnets():
+    raise SkipTest("create_subnet() does not support Ipv6Native-parameter yet")
+    client = boto3.client("elasticache", region_name="us-east-2")
+    ec2_client = boto3.client("ec2", region_name="us-east-2")
+
+    vpc = ec2_client.create_vpc(
+        CidrBlock="10.0.0.0/16", Ipv6CidrBlock="2600:1f16:1cb1:6b00::/56"
+    ).get("Vpc")
+
+    vpc_id = vpc.get("VpcId")
+
+    subnet_ipv6_0 = ec2_client.create_subnet(
+        VpcId=vpc_id,
+        Ipv6CidrBlock="2600:1f16:1cb1:6b00::/60",
+        Ipv6Native=True,
+    )
+    subnet_ipv6_0_id = subnet_ipv6_0.get("SubnetId")
+
+    subnet_ipv6_1 = ec2_client.create_subnet(
+        VpcId=vpc_id, Ipv6CidrBlock="2600:1f16:1cb1:6b10::/60", Ipv6Native=True
+    )
+    subnet_ipv6_1_id = subnet_ipv6_1.get("SubnetId")
+
+    resp = client.create_cache_subnet_group(
+        CacheSubnetGroupName="test-subnet-group",
+        CacheSubnetGroupDescription="Test subnet group",
+        SubnetIds=[subnet_ipv6_0_id, subnet_ipv6_1_id],
+    )
+
+    assert resp["CacheSubnetGroup"]["Subnets"][0]["SupportedNetworkTypes"] == ["ipv6"]
+    assert resp["CacheSubnetGroup"]["Subnets"][1]["SupportedNetworkTypes"] == ["ipv6"]
+    assert resp["CacheSubnetGroup"]["SupportedNetworkTypes"] == ["ipv6"]