Table of Contents

DynaSharp.Testing

Test support for DynaSharp. It fakes the wire, so a test needs neither DynamoDB nor a mock, and runs the real signing, mapping and expressions.

You want to Use
Answer with canned responses and check what was sent StubHandler
Run code against state that it writes and reads back FakeDynamoDb
Test credentials that go through STS StubSts

A client for a test

StubHandler answers requests and records them. StubClient.Create makes a client over it: region us-east-1, static fake credentials, one attempt per call, no attempt timeout, and nothing read from the environment or the shared AWS files. StubClient.CreateStreams does the same for DynamoDB Streams.

using var stub = new StubHandler();
using var client = StubClient.Create(stub);
stub.Enqueue(StubResponse.Item(customer));

var found = await client.Table<Customer>("Shop").Get(Customer.Keys.Of("alice")).ItemAsync(ct);

A request takes the next queued response, else the answer of the responder given to the constructor, else DefaultResponse, else it fails. A StubResponse taken by an operation it is not written for throws, naming both operations and its position in the queue. Responses left in the queue are no error; call stub.ThrowIfResponsesLeft() at the end of a test to check that all were taken. The second argument of Create changes the options:

using var retrying = StubClient.Create(stub, options => options with { MaxAttempts = 4, TimeProvider = clock });

AttemptTimeout runs on TimeProvider, so with a fake clock it would time out the request the test is answering; set one only in a test of a timeout.

The timeout of WaitUntilActiveAsync and WaitUntilActive() runs on that clock too. A loop that advances the clock with Task.Yield until the wait ends burns the whole timeout, and the wait throws TableNotActiveException with answers still queued. Give the wait a timeout the loop does not reach, or advance the clock once per poll the handler has seen.

Answers

StubResponse writes DynamoDB's answers from entities, so a test holds no JSON.

For Use
Items Item, Items, Count, Attributes, BatchItems, Unprocessed
Tables Table, Table<T>, TableCreated, TableUpdated, TableDeleted, TablesListed, TimeToLive, TimeToLiveUpdated
Streams StreamDescription, Shard, ShardIterator, Records, which takes Inserted<T>, Modified<T>, Removed<T> and Change
Failures ConditionalCheckFailed, TransactionCanceled, ResourceNotFound, ResourceInUse, DynamoDbError
Anything else Json
Answer Answers
Item GetItem
Items, Count Query, Scan
Attributes, ConditionalCheckFailed PutItem, UpdateItem, DeleteItem
TransactionCanceled both transactions
BatchItems, Unprocessed their batch
Tables and streams the one call each describes, such as TableUpdated for UpdateTable
ResourceNotFound, ResourceInUse, DynamoDbError, Json, new StubResponse() any operation

TablesListed with lastEvaluatedTableName is a page with more to follow. Table<T>(name) lists the entity's keys and indexes as active, so a wait for its indexes ends on it; a table of several entities takes Table(name, status, indexes).

StreamDescription(table, shards) describes an ENABLED stream, and an enabled stream can still open shards, so ReadStreamAsync keeps looking for the children of a closed shard and never ends. A closed stream must say DISABLED: a script whose last shard closes answers StreamDescription(table, StreamStatus.Disabled, shards), which ends the reading.

What was sent

stub.Requests holds every request. A RecordedRequest has Operation, Body, BodyText and BodyJson, and reads an item or key back with ReadItem<T>() and ReadKey<T>(). TableName, IndexName, ConsistentRead, ScanIndexForward and Limit give those members as sent, or null where absent: a batch or transaction names a table per entry, and a command sends ConsistentRead only as true and ScanIndexForward only as false.

ConditionText, UpdateText, KeyConditionText, FilterText and ProjectionText give an expression with its placeholders resolved, or null.

await client.Table<Product>("Shop").Update(Product.Keys.Of("KB-01"))
    .Set(Product.Attrs.Price, 79.00m)
    .If(Product.Attrs.Price.Eq(95.00m))
    .SendAsync(ct);

var sent = stub.Requests[^1];
Console.WriteLine(sent.ConditionText); // Price = 95.00
Console.WriteLine(sent.UpdateText);    // SET Price = 79.00, _type = if_not_exists(_type, "PRODUCT")

Keywords, functions and spacing stay as sent, and a placeholder missing from the maps stays as it is, so the test sees the mistake. A name appears as is, in backticks if it holds a dot or a bracket. A value reads in PartiQL notation with double quotes:

Stored Text
String, number "Placed", 3.10
Boolean, null true, null
Binary b"aGk="
List, map ["a", 1], {"k": 1}
Set <<"a", "b">>, <<1>>, <<b"aA==">>

TransactItems gives a transaction's steps, each a RecordedTransactStep with Kind, TableName, ReadItem<T>(), ReadKey<T>() and the same texts. RecordingHandler records the Exchanges of a real client. ToRequestJson() of a command shows the request without any client.

A fake DynamoDB

FakeDynamoDb is an in-memory DynamoDB behind the same seam. A test sets up tables and items, runs the code under test through a real client, and reads the state back.

var fake = new FakeDynamoDb();
fake.CreateTable<Customer>();
using var client = fake.CreateClient();

await client.Table<Customer>().Put(customer).SendAsync(ct);

var stored = fake.Get<Customer>(StubResponse.KeyOf(Customer.Keys.Of("alice")));

Create a table with a CreateTable over the wire, CreateTable().SendAsync, fake.CreateTable(request), fake.CreateTable<T>(), which creates the table the entity declares with its keys and indexes as its CreateTable() would, fake.CreateTable<T>(name), the same under another name, or fake.CreateTable(name, "pk", "sk") for the table of client.Table(name, "pk", "sk"), without a model, whose keys are strings, such as the table of the locks of DynaSharp.Coordination. Any other table is ResourceNotFoundException. A table and its indexes are ACTIVE in the answer that creates them, and DeleteTable frees the name at once.

A table of several entities is best created the way the application creates it, through a client of the fake. Where the application calls CreateTable().IncludingAll(), the method the generator writes for an assembly whose entities share the keys of one table, the test runs that same code over fake.CreateClient(), and the fake gets every index of every entity of that assembly, see Every entity of an assembly. fake.CreateTable<T>(name) declares one entity, and the test project's own IncludingAll() names the test project's entities, not the application's.

The forms without a table name, CreateTable<T>(), Put(entity), Get<T>(key) and Items<T>(), use the table the entity declares, with [DynamoDbEntity(Table = "...")] or the default of its assembly, and throw InvalidOperationException for an entity that declares none. They take the name as declared: the fake knows no TableNaming, so a test whose client sets one, or that wants a table of its own, passes the name, as in fake.CreateTable<Customer>("dev-Shop").

Put, Get, Delete, Items and their entity forms set up and read items without a client, through the checks of a request: a key that does not match the schema throws the ValidationException a client would get. DescribeTable, DeleteTable, TableNames and Reset do the same for tables. The fake is thread-safe, holds no static state and needs no disposing: make one per test with new.

On a table of several entities, Items<T> gives the items of T and skips the others, as a typed Scan() does, so a single-table design reads back one entity at a time:

var fake = new FakeDynamoDb();
fake.CreateTable<Customer>();
fake.Put(customer);
fake.Put(order);

IReadOnlyList<Customer> stored = fake.Items<Customer>();

An item of T that T cannot read still throws DynamoDbMappingException, and so does Get<T> for an item of another entity on that key. What the fake does has the rules.

fake.CreateClient() is StubClient.Create(fake.CreateHandler()), a StubHandler whose responder is the fake. Requests records what the client sent, and a response queued with Enqueue answers the next request instead of the fake, which never sees it: a queued throttle changes no state, as on DynamoDB.

The constructor's clock dates the tables. Time to live is stored and reported, but nothing expires by itself: fake.ExpireItems() runs DynamoDB's background sweep when the test says, deleting every item whose time to live attribute is a number of epoch seconds at or before that clock. An expiry more than five years before the clock is left alone, as on DynamoDB. The sweep reads tables with time to live on: fake.CreateTable<T> turns it on for an entity with a [TimeToLive] property, as CreateTable() of the client does, and a table of another kind gets it from an UpdateTimeToLive, or from CreateTable().TimeToLive(...) through a client of the fake.

Reads and writes behave as on DynamoDB: queries, segmented scans, projections, pages cut by Limit before the filter and at 1 MB with LastEvaluatedKey holding the index key too, conditions with ReturnValues and ReturnValuesOnConditionCheckFailure, batches, and all-or-nothing transactions with one cancellation reason per action. An optimistic lock, a create that must not overwrite, or a failed transaction condition leaves the item as it was. A ClientRequestToken makes a transaction idempotent for 10 minutes on the fake's clock. Consumed capacity follows the Developer Guide, indexes included. A batch never leaves unprocessed items or keys; to test a client that retries them, queue that answer on the handler.

A table with a stream records its changes in a shard, which fake.SplitStreamShards(table) splits, and fake.CreateStreamsClient() reads them through a DynamoDbStreamsClient, as StubClient.CreateStreams makes it, waiting on the clock of the fake. fake.EnableStream(table, viewType) and fake.DisableStream(table) switch the stream as an UpdateTable with a StreamSpecification does: an enable makes a new stream with a new LatestStreamArn, a disable closes its shards. So the code that consumes a stream is tested with the code that writes:

var fake = new FakeDynamoDb();
fake.CreateTable<Customer>("Shop");
fake.EnableStream("Shop", StreamViewType.NewAndOldImages);
using var client = fake.CreateClient();
using var streams = fake.CreateStreamsClient();

var shop = client.Table<Customer>("Shop");
await shop.Put(customer).SendAsync(ct);

await foreach (var change in shop.ReadChangesAsync(streams, ShardIteratorType.TrimHorizon, cancellationToken: ct))
{
    Console.WriteLine($"{change.Kind} {change.After!.Name}"); // Insert, and the name
    break;
}

ReadChangesAsync gives the changes as entities, as on DynamoDB, and ReadStreamAsync the records, whose As<T>() types one. Code that only handles changes needs no stream at all: a test builds a Change<T>, a record, and a MixedChange with the constructor of its type, new MixedChange<Customer, Order>(new Change<Order> { ... }), see Streams. The shards of a disabled stream are closed, so a read of one, or of the whole stream, ends: fake.DisableStream("Shop") ends a reading that a test runs to its end, and DescribeStream says DISABLED.

A reader that finds nothing waits before it asks again: EmptyPageDelay, a second by default, and at least a second before it looks for the children of a shard again. Against DynamoDB that keeps it from polling an idle shard without pause. On a client of CreateStreamsClient those waits also end at the next change of a stream of the fake: a write, a split, an enable or a disable. So a test reads a write in milliseconds, a reading ends as soon as the stream is disabled, and neither the test nor the code under test sets EmptyPageDelay or moves a clock for it. The reader sends the requests it sends to DynamoDB, an empty page, a wait and the next page; only the wait is shorter. A child that secondChildListedAfter lists late and a retry after a failure of FailNext still wait on the clock, which the test moves. Because a write ends the wait, how much clock time passes before a reader of this client sees a write is not EmptyPageDelay: a test that measures the delay itself, such as one that checks the emptyPageDelay its code passes, reads through a client of StubClient.CreateStreams over a script of answers, whose wait is the whole delay on the clock of its options.

What the fake does lists every operation, rule and limit. It never ignores a member of a request: an unimplemented member fails with the code UnsupportedByFakeException, an unanswered operation with UnknownOperationException.

Failing a call

fake.FailNext(operation, failure, times = 1, when = null) makes the next requests of an operation fail, through every handler of the fake, those made before too, so a client of CreateClient and one of CreateStreamsClient alike. The fake never sees such a request, as DynamoDB never sees one it throttles, so a failed write writes nothing, and the requests after it are answered by the fake. ThrowIfFailuresLeft() fails a test whose code sent fewer requests than it planned failures for, as ThrowIfResponsesLeft() does for a handler:

var fake = new FakeDynamoDb();
fake.CreateTable<Customer>("Shop");
using var client = fake.CreateClient();
fake.FailNext("PutItem", StubResponse.DynamoDbError("InternalServerError", "Failure of the service.", System.Net.HttpStatusCode.InternalServerError));

try
{
    await client.Table<Customer>("Shop").Put(customer).SendAsync(ct);
}
catch (InternalServerErrorException)
{
    // The fake holds no customer: the request never reached it.
}

fake.ThrowIfFailuresLeft();

A client of the fake makes one attempt per call, so a failure reaches the code under test at once. A test of the retries asks for more attempts: fake.CreateClient(options => options with { MaxAttempts = 3 }) repeats a throttle or a server error as it does on DynamoDB. ReadStreamAsync reads a shard on after a failure that its client gave up on, waiting on the clock of the fake, which the test moves:

var fake = new FakeDynamoDb(clock);
using var streams = fake.CreateStreamsClient();
fake.FailNext("GetRecords", StubResponse.DynamoDbError("ThrottlingException", "Rate exceeded."), times: 2);
// ReadStreamAsync over streams reads on after the two throttles, once the test moves the clock past its waits.
  • Which requests. A request takes the first planned failure of its operation, GetRecords or its whole target, that when accepts, such as request => request.BodyJson.GetProperty("TableName").GetString() == "Orders" for one table; when runs under the lock of the fake and must not call it. times is how many requests get it.
  • Threads. The shard readers of ReadStreamAsync send at once; each failure is taken by one request, under the lock of the fake.
  • The handler first. A response queued with Enqueue on a handler still answers before the fake is asked, so before a planned failure.
  • Reset. Reset() drops the failures no request took. The direct API, Put or Items, is no request and takes none.

For a write whose response is lost, the write must happen and the client must see an error, which FailNext does not do. Let the fake answer and return an error instead:

var fake = new FakeDynamoDb();
using var lost = new StubHandler(request =>
{
    var answer = fake.Answer(request);
    return request.Operation == "PutItem"
        ? StubResponse.DynamoDbError("InternalServerError", "The response was lost.", System.Net.HttpStatusCode.InternalServerError)
        : answer;
});

STS

A credentials provider does not go through HttpHandler, so StubSts is a server: HTTP on 127.0.0.1 and a free port, from the constructor to Dispose. It answers AssumeRoleWithWebIdentity and AssumeRole with the next queued StubStsResponse, else DefaultResponse, and records Requests.

using var sts = new StubSts();
sts.Enqueue(StubStsResponse.Credentials(new AwsCredentials("ASIAIOSFODNN7EXAMPLE", "secret", "token")));

var provider = new WebIdentityCredentialsProvider(tokenFile, roleArn, sts.Endpoint);
var credentials = await provider.GetCredentialsAsync(ct);
Console.WriteLine(sts.Requests[0].RoleArn);

That constructor reads no variable, so the test can run in parallel. To go through the default chain as a pod does, set AWS_ENDPOINT_URL_STS to sts.Endpoint, with AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_ARN, and do not run that test in parallel. Without AWS_ENDPOINT_URL_STS the token goes to the real STS in the test's region, or the fetch fails without a region. The stub never touches the environment or the disk: the test writes the token file.

AssumeRoleCredentialsProvider goes to the stub through StsEndpoint, and reads no variable when Region is set too. The stub takes any signature; the form is in Requests, with ExternalId and DurationSeconds.

using var sts = new StubSts();
sts.DefaultResponse = StubStsResponse.Credentials(new AwsCredentials("ASIAIOSFODNN7EXAMPLE", "secret", "token"));

var source = new StaticCredentialsProvider("AKIAIOSFODNN7EXAMPLE", "source-secret");
var options = new AssumeRoleOptions { ExternalId = "partner-7", Region = AwsRegion.FromName("eu-west-1"), StsEndpoint = sts.Endpoint };
var provider = new AssumeRoleCredentialsProvider(source, roleArn, options: options);
await provider.GetCredentialsAsync(ct);
Console.WriteLine(sts.Requests[0].ExternalId);

Credentials without an expiration go out with 2100-01-01T00:00:00Z, as STS always sends one, so the provider never refreshes them. It refreshes in the background from 15 minutes before the expiration and blocks callers from 10 minutes before. A test of a refresh sets the expiration it needs, relative to its clock.

This page is the README of DynaSharp.Testing.

What the fake does

FakeDynamoDb is for functional tests: state a test sets up and reads back through the client. For DynamoDB's fine print (validation order, messages, limits), test against an emulator or DynamoDB; a client for an emulator is new DynamoDbClient(DynamoDbClientOptions.ForEmulator(new Uri("http://localhost:8000"))), in us-east-1 with the keys local and local, which pick the database of DynamoDB Local without -sharedDb, see Run a local emulator. The fake is measured against tiers 1 and 2 of the Parity Suite.

Operations

Every item operation, batch and transaction below also takes ReturnConsumedCapacity, and every write ReturnItemCollectionMetrics.

Operation What it does
CreateTable Validates key schema, attribute definitions, indexes, projections, billing mode and throughput, and enables the stream of StreamSpecification. SSESpecification with Enabled: true is described as SSEDescription with ENABLED, KMS and a key ARN: that of KMSMasterKeyId (in the fake's region and account unless already an ARN), else one fixed AWS managed key. Nothing is encrypted. OnDemandThroughput of the table and each global index is kept and described, never enforced: each maximum is 1 to 40,000 or -1 for none, and with PROVISIONED it is a ValidationException. Table and indexes are ACTIVE in the answer. A taken name is ResourceInUseException.
DescribeTable Current item count and size of the table and each index, StreamSpecification while the stream is enabled, LatestStreamArn and LatestStreamLabel of the stream enabled last, SSEDescription for a table encrypted with a KMS key, and OnDemandThroughput of the table and each global index where a maximum is set.
UpdateTable Billing mode, provisioned throughput and on-demand maximums of the table and each global index, server-side encryption (SSESpecification as in CreateTable, Enabled: false back to the key DynamoDB owns), table class, deletion protection, stream on or off, and one global index created or deleted per call. A maximum left out keeps its value, -1 removes it, and a switch to PROVISIONED drops them all. A new index's key attributes must be in the request's AttributeDefinitions, not just the table's. It holds the table's items in the answer.
DeleteTable Deletes the table and its items, frees the name at once and disables its stream. With deletion protection it is a ValidationException.
ListTables Names in ordinal order, with Limit, ExclusiveStartTableName, and LastEvaluatedTableName when more follow.
DescribeTimeToLive, UpdateTimeToLive ENABLED or DISABLED at once. Enabling twice, or disabling what is off, is a ValidationException.
DescribeLimits Account defaults: 80,000 units per account, 40,000 per table.
GetItem The item, or what ProjectionExpression takes of it, with ConsistentRead.
PutItem, DeleteItem Replace or delete the item of the key under ConditionExpression, with ReturnValues NONE or ALL_OLD and ReturnValuesOnConditionCheckFailure.
UpdateItem UpdateExpression on the item of the key, creating it from the key if missing, under ConditionExpression, with all five ReturnValues and the options of PutItem. Without an update expression it only creates a missing item.
BatchGetItem Up to 100 keys over several tables, with ConsistentRead and ProjectionExpression per table. Responses has a list for every table requested.
BatchWriteItem Up to 25 puts and deletes over several tables. All are written, or none if one is rejected.
TransactGetItems Up to 100 gets at one moment: one Responses element per get, in order, {} for a missing item or an empty projection, with ProjectionExpression.
TransactWriteItems Up to 100 condition checks, puts, deletes and updates over several tables, all or nothing, with ClientRequestToken.
Query One partition of the table or an index by KeyConditionExpression, in sort key order (reversed with ScanIndexForward: false), with FilterExpression, ProjectionExpression, Select, Limit, ExclusiveStartKey and ConsistentRead.
Scan Every item of the table or an index, or of one Segment of TotalSegments, with the options of Query except key condition and direction.
ListStreams The streams of all tables or of the one named, in the order enabled, with Limit, ExclusiveStartStreamArn, and LastEvaluatedStreamArn when more follow. A table without a stream, an unknown table or an unlisted ExclusiveStartStreamArn gives an empty list.
DescribeStream Status, view type, key schema and the shards in the order of their ids, each with its ParentShardId: open (no EndingSequenceNumber) while the stream is enabled and the shard has not split, closed then. ExclusiveStartShardId and Limit page the shards, with LastEvaluatedShardId when more follow. ShardFilter with CHILD_SHARDS lists the children of the shard it names. A shard closed 24 hours ago is not listed, nor a child before its time to be listed has come.
GetShardIterator TRIM_HORIZON (oldest record kept), LATEST (after the newest), AT_SEQUENCE_NUMBER and AFTER_SEQUENCE_NUMBER. AT_SEQUENCE_NUMBER at the StartingSequenceNumber of a shard reads from its first record kept, and is TrimmedDataAccessException once a record of it is trimmed. SequenceNumber with TRIM_HORIZON or LATEST is a ValidationException. A shard closed 24 hours ago is ResourceNotFoundException.
GetRecords Records from the iterator on, up to Limit (default 1,000) and 1 MB, and NextShardIterator, left out on the last page of a closed shard. An open shard read to its end gives an empty page with a next iterator.

What a test can rely on

  • Consistency. Every read, and every index, sees the last write, with or without ConsistentRead.
  • Keys and sizes. A key with an attribute too many, too few or of the wrong type is The provided key element does not match the schema, as is such an item of BatchWriteItem; an item of PutItem or a transaction gets Missing the key pk in the item or the type mismatch. Empty strings and binaries in a key are rejected. An index key attribute whose type differs from its definition is a ValidationException. Limits: partition key 2048 bytes, sort key 1024, of the table and of an index (Size limit exceeded), item 400 KB, sized as the Developer Guide counts.
  • Indexes. An item is in a secondary index if it holds all of the index's key attributes, projected by ALL, KEYS_ONLY or INCLUDE. A global index created by UpdateTable leaves out items whose index key is of another type, empty or too large.
  • Numbers and sets. Numbers keep 38 significant digits, from 1E-130 to below 1E+126, and return in canonical plain digits: 01.50 as 1.5, 1E+2 as 100; key 1.0 is key 1, and a BatchGetItem with both returns the item once. Sets return strings and binaries in byte order, numbers by value. Sets with a duplicate and documents nested deeper than 32 levels are rejected.
  • Order. Sort keys order strings by UTF-8 bytes, binaries by bytes, numbers by value. Partition order is fixed per run; do not depend on it.
  • Paging. Limit counts the items read, before the filter, and a page also ends at 1 MB read, by item size. A page cut by either carries LastEvaluatedKey, even when nothing follows or the filter dropped every item, so an empty page need not be the last; but a Query whose key condition has a sort key part leaves it out when Limit ends the page at the last item the condition matches, as DynamoDB does. The key holds the table key, plus the index key for an index read. ExclusiveStartKey must be such a key, within the partition and sort key range of a query or the segment of a scan; an empty one is a ValidationException.
  • Count and Select. Count is the items that passed the filter, ScannedCount the items read. Select defaults to all attributes on a table, projected ones on an index, named ones with a projection. A local index fetches from its table what it does not hold. A query filter may read only what the index projects; a scan filter on an index sees the table item. ValidationException: SPECIFIC_ATTRIBUTES without a projection, a projection with any other Select, ALL_PROJECTED_ATTRIBUTES on a table, ALL_ATTRIBUTES on a global index that does not project everything, a projection naming an attribute a global index does not project (Global secondary index <name> does not project [<attributes>]), a query filter on an attribute the index does not project (Secondary index <name> does not project one or more filter attributes: [<attributes>]), a query filter on a key attribute, and ConsistentRead on a global index.
  • Parallel scan. Each item is in exactly one segment, fixed per run; do not depend on which.
  • Projection. Takes the paths it names, nested ones and list elements included, and the key only if named.
  • Read capacity. As in the Developer Guide: 1 unit per 4 KB, rounded up, at least 1, halved when eventually consistent. GetItem pays for the whole item, whatever it projects, and for a missing one; Query and Scan for the items read before the filter. With INDEXES, index reads are reported under the index, the table at 0 or at what a local index fetched.
  • Expressions. Grammar and type rules are those of the Developer Guide. Comparing different types is false. <, <=, >, >= and BETWEEN take strings, numbers and binaries; a constant of another type is a ValidationException. size of a string counts UTF-16 code units; a number, boolean or null has no size, so comparing it is false. ValidationException: a bare reserved word (but size in any case, a name where no parenthesis follows), a placeholder missing from ExpressionAttributeNames or ExpressionAttributeValues, a map entry no expression of the request uses, doubled parentheses such as ((a = :v)), and a comparison, BETWEEN, IN, begins_with or contains that takes its first path again, such as a = a or a BETWEEN a AND :v.
  • Failed conditions. ConditionalCheckFailedException with The conditional request failed, holding the old item under ReturnValuesOnConditionCheckFailure: ALL_OLD. A PutItem of an item DynamoDB does not store (an index key of another type or empty, more than 400 KB) is a ValidationException also where its condition fails. An update cannot change a key attribute. SET of list indexes past the end appends in index order: SET l[11] = :b, l[10] = :a on [x, y] gives [x, y, :a, :b].
  • ReturnValues. PutItem and DeleteItem take NONE or ALL_OLD; anything else is a ValidationException. ALL_OLD and ALL_NEW are the whole item before and after. UPDATED_OLD and UPDATED_NEW are the paths the update named, nested ones inside their parents (SET a.b = :v gives a with b alone); UPDATED_NEW leaves out paths only a REMOVE named. Attributes is left out when there is nothing to return, such as ALL_OLD of a new item.
  • Write capacity. 1 unit per KB of the larger of the item before and after, rounded up, at least 1, also for deleting a missing item. A secondary index adds 1 for an item entering or leaving it, 2 when its index key changes, 1 when a projected attribute changes, else 0. A transaction pays double per item, a condition check as a write of the item it reads; a transactional read is double a strongly consistent read. A batch rounds each item on its own. Batches and transactions report one ConsumedCapacity per table. With INDEXES a transaction also reports its units as ReadCapacityUnits (TransactGetItems and a replay) or WriteCapacityUnits (TransactWriteItems), at the top, per table and per index.
  • Item collections. ReturnItemCollectionMetrics: SIZE reports the collection of the partition key written, with SizeEstimateRangeGB [0, 1], for a table with a local secondary index, and nothing otherwise.
  • Batches. More than the limits above, a key twice, or a write with both or neither of PutRequest and DeleteRequest is a ValidationException. UnprocessedKeys and UnprocessedItems are always empty (see the README above).
  • Transactions. Every condition is checked against the items as they were before. If one fails, nothing is written and it is TransactionCanceledException with one CancellationReasons entry per action, in order: ConditionalCheckFailed, with the item where ALL_OLD asked; ValidationError for an action the item cannot take (such as an update past 400 KB, a path into a missing map, also beside a failed condition, or an update of a key attribute), for a key that does not match its table's schema, for a put of an index key of another type than the index defines, whatever its condition, and for an update whose result holds one, once its condition holds; None for the others. An empty string or binary in a key or an index key attribute, a too large index key, or a put of an item over 400 KB fails the whole request with ValidationException instead of a reason. One item twice is a ValidationException. A TransactGetItems with a key that does not match its table's schema is cancelled too, ValidationError for that get and None for the others.
  • Idempotent transactions. ClientRequestToken makes TransactWriteItems idempotent for 10 minutes on the constructor's clock. The same token with the same request changes nothing, succeeds and reports double a strongly consistent read of each item; with another request it is IdempotentParameterMismatchException. A cancelled transaction does not keep its token. Reset forgets every token.
  • Stream records. Each change of an item is an INSERT, MODIFY or REMOVE record, in write order with ascending sequence numbers, with the images of the view type: keys for KEYS_ONLY, the item after for NEW_IMAGE, before for OLD_IMAGE, both for NEW_AND_OLD_IMAGES. A write that changes nothing, such as the same PutItem again, records nothing. A transaction or batch records its changes together, a failed request none, the direct API as a request does. A record exists by the time its write answers. SizeBytes is the size of keys and images, counted as item sizes; ApproximateCreationDateTime is the fake's clock in seconds.
  • Stream lifecycle. Each enable by CreateTable, UpdateTable or EnableStream(table, viewType) makes a new stream, with a new LatestStreamArn and LatestStreamLabel; changing the view type takes one UpdateTable (or DisableStream(table)) to disable and one to enable. ValidationException: enabling an enabled stream, disabling one that is off, StreamEnabled: true without StreamViewType, and StreamEnabled: false with one. A disabled stream, also of a deleted table, stays readable and listed for 24 hours; then ListStreams omits it and reading it is ResourceNotFoundException. A stream enabled after Reset has a new ARN, also on the same clock, so an older iterator is ResourceNotFoundException.
  • Shards. CreateStreamsClient() makes a client that waits on the clock of the fake, so EmptyPageDelay and the looks of ReadStreamAsync pass as the test moves it; configure can set another clock. Those waits also end at the next change of a stream of the fake, a write, a split, an enable or a disable, so a reader on the system clock reads a write at once. A test that measures EmptyPageDelay reads through a client of StubClient.CreateStreams instead, see the README above. A stream starts with one shard. SplitStreamShards(table, children, secondChildListedAfter) ends every open shard and opens one child of each (a rollover) or two (a split); the children take the writes from then on, each the items of its share of the partition keys, so the changes of an item stay in order along the lineage. With secondChildListedAfter the second child takes writes at once and is listed only once the clock has moved on by that much, which is how DynamoDB can list a child late. A shard closed for 24 hours is gone, with its records.
  • Iterators. Valid 15 minutes on the constructor's clock, then ExpiredIteratorException; every page hands out a new one. Records are kept 24 hours; an older position is TrimmedDataAccessException. A sequence number outside the shard's range, or a text that is no iterator, is a ValidationException.
  • Time to live. ExpireItems() records each delete as a REMOVE with the userIdentity {"Type":"Service","PrincipalId":"dynamodb.amazonaws.com"}, as DynamoDB records time to live deletes. A client's delete has none. It sweeps tables with time to live on, which CreateTable<T> turns on for an entity with [TimeToLive].
  • Entities in the direct API. Items<T> of an entity with a discriminator reads only the items whose discriminator attribute, _type or the one the entity names, is a string equal to its discriminator, as Item.Is<T>() tests, and skips every other item: those of other entities and those without the attribute, which a typed Scan() filters out too. An item of the entity that it cannot read throws DynamoDbMappingException. An entity with [Discriminator(OptOut = true)], and a document such as Item, have nothing that tells items apart, so Items<T> reads every item, and one it cannot read throws. Get<T> reads the one item of its key and throws for an item of another entity, as Get(key).ItemAsync() does. Without a table name, CreateTable<T>(), Put(entity), Get<T>(key) and Items<T>() take the table the entity declares, as declared, without the TableNaming of a client.
  • Error responses. HTTP 400 with DynamoDB's code in __type, so the client throws what it throws for DynamoDB. Every answer carries x-amzn-RequestId.

What the fake is not

  • Nothing throttles: provisioned throughput is stored and reported, never enforced. Faults come from FailNext or from the handler, as in the README above.
  • No automatic expiry: see ExpireItems() in the README above.
  • No PartiQL, backups, point-in-time recovery, export or import, tags, resource policies, global tables, warm throughput, Contributor Insights or Kinesis destinations.
  • Shards split only when SplitStreamShards says so, never by themselves. A stream is ENABLED or DISABLED at once, never ENABLING or DISABLING.
  • Not concurrent: no TransactionConflict or TransactionInProgress, and a batch is as atomic as a transaction.
  • Not the fine print: the exact validation order, the wording of every message, the 10 GB item collection limit and its exact size estimate, the 16 MB batch limit, x-amz-crc32 as a checksum.

How it answers what it does not do

It fails loudly, never silently.

  • An operation it does not answer, such as PartiQL: HTTP 400 with UnknownOperationException and FakeDynamoDb does not implement <Op>. It is not supported.
  • A member it does not implement in an operation it answers: HTTP 400 with the code UnsupportedByFakeException (in DynamoDbException.ErrorCode) and a message naming the member and the operation. In item operations, batches, transactions and reads these are the legacy parameters: AttributesToGet, AttributeUpdates, Expected, KeyConditions, QueryFilter and ScanFilter. In table operations: tags, and any member the fake does not know, such as WarmThroughput or ReplicaUpdates.
  • A value DynamoDB rejects gets DynamoDB's ValidationException, also where the fake implements little: an enum value DynamoDB lacks, such as "AttributeType":"BOOL"; ExpressionAttributeNames or ExpressionAttributeValues without an expression; a legacy parameter together with an expression (Can not use both expression and non-expression parameters in the same request).

Locks

A LockTable of DynaSharp.Coordination runs on a client of the fake, over a table that fake.CreateTable("Shop", "pk", "sk") creates, and the extensions of DynaSharp.Coordination.Testing let a test do what another process would: hold the lock (SimulateHolderAsync), release it (SimulateReleaseAsync) or delete its item (SimulateDeleteAsync), and check what happened: the state of the lock (GetStateAsync) and a contender that was turned away (WaitForRefusalAsync), without knowing the format of the item. They live in the Coordination package, so DynaSharp.Testing does not depend on it. See Testing in the guide of the locks.