
Elixir Writing Docs
- 81 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
elixir-writing-docs is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- elixir-writing-docs
- AI & Agent Building
- AI-coding skill
Elixir Writing Docs by the numbers
- 81 all-time installs (skills.sh)
- Ranked #5,216 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill elixir-writing-docsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Elixir Writing Docs
Quick Reference
| Topic | Reference |
|---|---|
| Doctests: syntax, gotchas, when to use | references/doctests.md |
| Cross-references and linking syntax | references/cross-references.md |
| Admonitions, formatting, tabs | references/admonitions-and-formatting.md |
First-Line Summary Rule
ExDoc and tools like mix docs extract the first paragraph of @moduledoc and @doc as a summary. Keep the opening line concise and self-contained.
# GOOD - first line works as a standalone summary
@moduledoc """
Handles payment processing through Stripe and local ledger reconciliation.
Wraps the Stripe API client and ensures each charge is recorded in the
local ledger before returning a confirmation to the caller.
"""
# BAD - first line is vague, forces reader to continue
@moduledoc """
This module contains various functions related to payments.
It uses Stripe and also updates the ledger.
"""The same rule applies to @doc:
# GOOD
@doc """
Charges a customer's default payment method for the given amount in cents.
Returns `{:ok, charge}` on success or `{:error, reason}` when the payment
gateway rejects the request.
"""
# BAD
@doc """
This function is used to charge a customer.
"""@moduledoc Structure
A well-structured @moduledoc follows this pattern:
defmodule MyApp.Inventory do
@moduledoc """
Tracks warehouse stock levels and triggers replenishment orders.
This module maintains an ETS-backed cache of current quantities and
exposes functions for atomic stock adjustments. It is designed to be
started under a supervisor and will restore state from the database
on init.
## Examples
iex> {:ok, pid} = MyApp.Inventory.start_link(warehouse: :east)
iex> MyApp.Inventory.current_stock(pid, "SKU-1042")
{:ok, 350}
## Configuration
Expects the following in `config/runtime.exs`:
config :my_app, MyApp.Inventory,
repo: MyApp.Repo,
low_stock_threshold: 50
"""
endKey points:
- First paragraph is the summary (one to two sentences).
## Examplesshows realistic usage. Use doctests when the example is runnable.## Configurationdocuments required config keys. Omit this section if the module takes no config.- Use second-level headings (
##) only. First-level (#) is reserved for the module name in ExDoc output.
Documenting Behaviour Modules
When defining a behaviour, document the expected callbacks:
defmodule MyApp.PaymentGateway do
@moduledoc """
Behaviour for payment gateway integrations.
Implementations must handle charging, refunding, and status checks.
See `MyApp.PaymentGateway.Stripe` for a reference implementation.
## Callbacks
* `charge/2` - Initiate a charge for a given amount
* `refund/2` - Refund a previously completed charge
* `status/1` - Check the status of a transaction
"""
@callback charge(amount :: pos_integer(), currency :: atom()) ::
{:ok, transaction_id :: String.t()} | {:error, term()}
@callback refund(transaction_id :: String.t(), amount :: pos_integer()) ::
:ok | {:error, term()}
@callback status(transaction_id :: String.t()) ::
{:pending | :completed | :failed, map()}
end@doc Structure
@doc """
Reserves the given quantity of an item, decrementing available stock.
Returns `{:ok, reservation_id}` when stock is available, or
`{:error, :insufficient_stock}` when the requested quantity exceeds
what is on hand.
## Examples
iex> MyApp.Inventory.reserve("SKU-1042", 5)
{:ok, "res_abc123"}
iex> MyApp.Inventory.reserve("SKU-9999", 1)
{:error, :not_found}
## Options
* `:warehouse` - Target warehouse atom. Defaults to `:primary`.
* `:timeout` - Timeout in milliseconds. Defaults to `5_000`.
"""
@spec reserve(String.t(), pos_integer(), keyword()) ::
{:ok, String.t()} | {:error, :insufficient_stock | :not_found}
def reserve(sku, quantity, opts \\ []) do
# ...
endGuidelines:
- State what the function does, then what it returns.
- Document each option in a bulleted
## Optionssection when the function accepts a keyword list. - Place
@specbetween@docanddef. This is the conventional ordering. - Include doctests for pure functions. Skip them for side-effecting functions (see references/doctests.md).
@typedoc
Document custom types defined with @type or @opaque:
@typedoc """
A positive integer representing an amount in the smallest currency unit (e.g., cents).
"""
@type amount :: pos_integer()
@typedoc """
Reservation status returned by `status/1`.
* `:held` - Stock is reserved but not yet shipped
* `:released` - Reservation was cancelled and stock restored
* `:fulfilled` - Items have shipped
"""
@type reservation_status :: :held | :released | :fulfilled
@typedoc """
Opaque handle returned by `connect/1`. Do not pattern-match on this value.
"""
@opaque connection :: %__MODULE__{socket: port(), buffer: binary()}For @opaque types, the @typedoc is especially important because callers cannot inspect the structure.
Metadata
@doc since and @doc deprecated
@doc since: "1.3.0"
@doc """
Transfers stock between two warehouses.
"""
def transfer(from, to, sku, quantity), do: # ...
@doc deprecated: "Use transfer/4 instead"
@doc """
Moves items between locations. Deprecated in favor of `transfer/4`
which supports cross-region transfers.
"""
def move_stock(from, to, sku, quantity), do: # ...You can combine metadata and the docstring in one attribute:
@doc since: "2.0.0", deprecated: "Use bulk_reserve/2 instead"
@doc """
Reserves multiple items in a single call.
"""
def batch_reserve(items), do: # ...@moduledoc since: works the same way for modules:
@moduledoc since: "1.2.0"
@moduledoc """
Handles webhook signature verification for Stripe events.
"""When to Use @doc false / @moduledoc false
Suppress documentation when the module or function is not part of the public API:
# Private implementation module — internal to the application
defmodule MyApp.Inventory.StockCache do
@moduledoc false
# ...
end
# Protocol implementation — documented at the protocol level
defimpl String.Chars, for: MyApp.Money do
@moduledoc false
# ...
end
# Callback implementation — documented at the behaviour level
@doc false
def handle_info(:refresh, state) do
# ...
end
# Helper used only inside the module
@doc false
def do_format(value), do: # ...Do NOT use `@doc false` on genuinely public functions. If a function is exported and callers depend on it, document it. If it should not be called externally, make it private with defp.
Documentation vs Code Comments
Documentation (@moduledoc, @doc) | Code Comments (#) | |
|---|---|---|
| Audience | Users of your API | Developers reading source |
| Purpose | Contract: what it does, what it returns | Why a particular implementation choice was made |
| Rendered | Yes, by ExDoc in HTML/epub | No, visible only in source |
| Required | All public modules and functions | Only where code intent is non-obvious |
@doc """
Validates that the given coupon code is active and has remaining uses.
"""
@spec validate_coupon(String.t()) :: {:ok, Coupon.t()} | {:error, :expired | :exhausted}
def validate_coupon(code) do
# We query the read replica here to avoid adding load to the
# primary during high-traffic discount events.
Repo.replica().get_by(Coupon, code: code)
|> check_expiry()
|> check_remaining_uses()
endThe @doc tells the caller what validate_coupon/1 does and returns. The inline comment explains an implementation decision that would otherwise be surprising.
Completing documentation (gates)
Finish with these sequenced checks. Skip a step when it does not apply.
1. Doctests added or changed? Run the project’s doctest verification (usually mix test for affected modules or the full suite). Pass: no doctest failures. 2. Cross-references, backticks, or `m:` links added or edited? Run mix docs. Pass: the command completes; resolve ExDoc warnings about missing modules, callbacks, or bad links. 3. New or changed public API? Pass: every exported def / defmacro has an intentional @doc or @doc false, and every public module has @moduledoc or @moduledoc false, consistent with your project’s policy.
When to Load References
- Writing doctests or debugging doctest failures --> references/doctests.md
- Adding links between modules, functions, types --> references/cross-references.md
- Using admonition blocks, tabs, or formatting in docs --> references/admonitions-and-formatting.md
Admonitions and Formatting
Admonition Blocks
Admonitions are callout boxes rendered by ExDoc. They use blockquote syntax with a special heading:
> #### Watch out for atom exhaustion {: .warning}
>
> Calling `String.to_atom/1` on user input can exhaust the atom table.
> Use `String.to_existing_atom/1` instead.Structure
1. Start with > #### followed by the admonition title 2. Add {: .class} at the end of the title line 3. Follow with > blank line and > content lines
Available Classes
| Class | Use for | Rendered appearance |
|---|---|---|
.warning | Potential pitfalls, breaking changes | Yellow/amber box |
.error | Dangerous operations, common mistakes | Red box |
.info | Additional context, background | Blue box |
.tip | Best practices, performance hints | Green box |
.neutral | General callouts without urgency | Grey box |
Examples
@moduledoc """
Manages database connections for the application.
> #### Requires database access {: .info}
>
> This module expects a running PostgreSQL instance. See the
> [setup guide](setup.md) for local development configuration.
> #### Connection pooling {: .tip}
>
> For high-throughput workloads, increase the pool size in
> `config/runtime.exs`:
>
> config :my_app, MyApp.Repo,
> pool_size: 20
> #### Do not call at compile time {: .error}
>
> Functions in this module require the application to be started.
> Calling them in module attributes or at compile time will raise.
"""Multi-Paragraph Admonitions
Continue with > on each line:
@doc """
> #### Migration required {: .warning}
>
> After upgrading to v2.0, run the following migration:
>
> mix ecto.migrate
>
> This adds the `archived_at` column used by the new soft-delete
> feature. Existing rows will have `NULL` in this column, which
> the query functions treat as "not archived."
"""Heading Levels
In @moduledoc and @doc, use second-level headings (##) as the highest level. First-level headings (#) are reserved for the module or function name in ExDoc output.
# GOOD
@moduledoc """
Handles webhook delivery and retry logic.
## Retry Strategy
Failed deliveries are retried with exponential backoff.
## Configuration
Set the maximum retry count in your config.
"""
# BAD - # will clash with ExDoc's page title
@moduledoc """
# Webhook Delivery
Handles webhook delivery and retry logic.
"""Within reference documentation and extra pages, # is acceptable as a page title.
Tabbed Content
ExDoc supports tabbed content blocks using HTML comments and third-level headings:
@moduledoc """
## Installation
<!-- tabs-open -->
### Mix
Add to your `mix.exs` dependencies:
{:my_library, "~> 1.0"}
### Rebar3
Add to your `rebar.config`:
{deps, [{my_library, "1.0.0"}]}.
### Erlang.mk
Add to your `Makefile`:
dep_my_library = hex 1.0.0
<!-- tabs-close -->
"""Rules:
- Open with
<!-- tabs-open --> - Each tab is a
###heading - Close with
<!-- tabs-close --> - Content between
###headings becomes that tab's body - Tabs work in
@moduledoc,@doc, and extra pages
Realistic Example
@doc """
Serializes a struct to a transport format.
## Examples
<!-- tabs-open -->
### JSON
iex> MyApp.Serializer.encode(%User{name: "Alice"}, :json)
{:ok, ~s({"name":"Alice"})}
### MessagePack
iex> MyApp.Serializer.encode(%User{name: "Alice"}, :msgpack)
{:ok, <<129, 164, 110, 97, 109, 101, 165, 65, 108, 105, 99, 101>>}
<!-- tabs-close -->
"""Code Blocks
Use fenced code blocks with a language tag for syntax highlighting:
````elixir @moduledoc """
Usage
{:ok, conn} = MyApp.Connection.open("localhost", 5432)
MyApp.Connection.query(conn, "SELECT 1")Configuration in config/runtime.exs:
config :my_app, MyApp.Connection,
hostname: System.get_env("DB_HOST", "localhost"),
port: String.to_integer(System.get_env("DB_PORT", "5432"))""" ````
For shell commands:
````elixir @moduledoc """
Getting Started
mix deps.get
mix ecto.setup
mix phx.server""" ````
Indented Code Blocks in Doctests
Within ## Examples sections, use four-space indentation (not fenced blocks) so that ExDoc can detect and run doctests:
@doc """
## Examples
iex> MyApp.Math.add(2, 3)
5
"""Lists
Unordered Lists
@doc """
Supported formats:
* `:json` - JSON encoding via Jason
* `:msgpack` - MessagePack via Msgpax
* `:csv` - CSV encoding via NimbleCSV
"""Ordered Lists
@doc """
Processing pipeline:
1. Validate input against the schema
2. Transform to internal representation
3. Persist to the database
4. Broadcast change event
"""Nested Lists
@doc """
Options:
* `:format` - Output format
* `:json` - Default
* `:csv` - Comma-separated
* `:compress` - Whether to gzip the output
* `true` - Enable compression
* `false` - Default, no compression
"""Tables
@moduledoc """
## HTTP Status Mapping
| Status | Atom | Description |
|--------|------|-------------|
| 200 | `:ok` | Successful request |
| 201 | `:created` | Resource created |
| 400 | `:bad_request` | Invalid input |
| 404 | `:not_found` | Resource missing |
| 422 | `:unprocessable_entity` | Validation failed |
"""Tables must have a header row and a separator row. Alignment colons (:---, :---:, ---:) are supported.
Inline Formatting
| Syntax | Renders as | Use for |
|---|---|---|
` code ` | code | Module names, functions, atoms, options |
**bold** | bold | Emphasis on key terms |
*italic* | italic | Titles, introducing terms |
[text](url) | link | External URLs |
` [text](Module) ` | code link | Cross-references (see cross-references.md) |
Combining Formatting Techniques
A well-formatted @moduledoc uses several of these elements together:
defmodule MyApp.RateLimiter do
@moduledoc """
Token-bucket rate limiter backed by ETS.
Limits are configured per endpoint and enforced in the
`MyApp.Plugs.RateLimit` plug.
> #### Production configuration {: .tip}
>
> Set limits based on your capacity planning. Start conservative
> and adjust based on metrics from `MyApp.Telemetry`.
## Examples
iex> {:ok, limiter} = MyApp.RateLimiter.start_link(name: :api)
iex> MyApp.RateLimiter.check(limiter, "user:42", :search)
:allow
## Configuration
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `:window_ms` | `pos_integer()` | `60_000` | Window duration |
| `:max_requests` | `pos_integer()` | `100` | Requests per window |
| `:ban_duration_ms` | `pos_integer()` | `300_000` | Ban duration after exceeding limit |
## Architecture
<!-- tabs-open -->
### Single Node
Uses a local ETS table. Suitable for development and single-instance
deployments.
### Distributed
Wraps the ETS table with `:pg`-based synchronization. Each node
maintains its own counter and periodically reconciles with peers.
<!-- tabs-close -->
"""
endCross-References and Linking
ExDoc auto-links identifiers written in backtick-delimited code spans. This reference covers every linking syntax available.
Module Links
Reference another module by writing its name in backticks:
@moduledoc """
See `MyApp.Accounts` for user management functions.
"""If the module name collides with a local function or could be ambiguous, use the m: prefix:
@doc """
Delegates to `m:MyApp.Accounts` for persistence.
"""The m: prefix is also useful when linking to modules whose names look like function calls.
Function Links
Remote Functions (Other Modules)
@doc """
Similar to `MyApp.Accounts.get_user/1` but raises on failure.
Accepts the same options as `MyApp.Accounts.list_users/2`.
"""Local Functions (Same Module)
Omit the module name to link to a function in the current module:
@doc """
The bang variant of `fetch_config/1`. Raises `KeyError` if the key is missing.
"""Operators
@doc """
Works like the `Kernel.<>/2` operator but for lists.
"""Function Arity
Always include the arity. function/1 and function/2 are distinct links:
@doc """
See `transform/1` for the single-argument version, or
`transform/2` to pass options.
"""Type Links
Use the t: prefix to link to types:
@doc """
Returns a `t:MyApp.Money.amount/0` representing the balance.
Accepts any `t:Enumerable.t/0` as input.
"""For types in the same module:
@doc """
Returns a `t:result/0` tuple.
"""Callback Links
Use the c: prefix to link to behaviour callbacks:
@doc """
Invoked by the framework. See `c:GenServer.init/1` for details.
Implementations must satisfy `c:MyApp.PaymentGateway.charge/2`.
"""Erlang Module and Function Links
Erlang Modules
Use m: with the atom syntax:
@doc """
Uses `m::ets` for fast in-memory lookups.
"""Erlang Functions
Use the atom syntax directly:
@doc """
Wraps `:erlang.system_info/1` to fetch VM metrics.
Delegates to `:timer.send_interval/2` for periodic messages.
"""Erlang Types
@doc """
Returns a `t::erlang.reference/0`.
"""Custom Text Links
When you want link text that differs from the identifier, use markdown link syntax with a backtick-delimited destination:
@doc """
Returns a [money amount](`MyApp.Money.amount/0`) in the given currency.
See the [payment gateway behaviour](`MyApp.PaymentGateway`) for the
full contract.
Read the [validation rules](`MyApp.Validation.validate/2`) for details.
"""Cross-Application References
Link to documentation in other Hex packages or OTP apps using e::
@doc """
Follows the patterns described in the
[Elixir writing documentation guide](`e:elixir:writing-documentation.html`).
See [Plug.Conn](`e:plug:Plug.Conn.html`) for the full struct reference.
"""For functions in other apps:
@doc """
Wraps [`Ecto.Repo.transaction/2`](`e:ecto:Ecto.Repo.html#c:transaction/2`).
"""Linking to Extra Pages
If your project includes extra markdown pages in the ExDoc configuration, link to them by filename:
@moduledoc """
For deployment instructions, see the [Operations Guide](operations-guide.md).
Architecture decisions are documented in [ADR-001](adr/001-event-sourcing.md).
"""Summary of Prefixes
| Prefix | Links to | Example |
|---|---|---|
| (none) | Module or function | ` MyApp.Repo , fetch/1 ` |
m: | Module (explicit) | ` m:MyApp.Repo ` |
t: | Type | ` t:String.t/0 ` |
c: | Callback | ` c:GenServer.init/1 ` |
e: | Cross-app page | ` e:elixir:writing-documentation.html ` |
Common Mistakes
# BAD - missing arity
@doc "See `MyApp.Accounts.get_user` for details."
# GOOD - include arity
@doc "See `MyApp.Accounts.get_user/1` for details."
# BAD - using URL-style links for internal modules
@doc "See [MyApp.Accounts](https://hexdocs.pm/my_app/MyApp.Accounts.html)."
# GOOD - let ExDoc resolve the link
@doc "See `MyApp.Accounts`."
# BAD - linking to private functions (ExDoc will warn)
@doc "Uses `do_internal_parse/2` under the hood."
# GOOD - only link to public API
@doc "Uses an internal parser to process the input."Doctests
When to Use Doctests
Doctests serve double duty: they are runnable examples in your documentation and lightweight tests. Use them for:
- Pure functions with deterministic output
- String/data transformations where the input and output are easy to read
- Simple calculations and formatting helpers
- Demonstrating API usage to new developers
@doc """
Converts a price in cents to a formatted dollar string.
## Examples
iex> MyApp.Format.price_in_dollars(1050)
"$10.50"
iex> MyApp.Format.price_in_dollars(0)
"$0.00"
iex> MyApp.Format.price_in_dollars(7)
"$0.07"
"""
def price_in_dollars(cents) when is_integer(cents) and cents >= 0 do
"$#{div(cents, 100)}.#{cents |> rem(100) |> Integer.to_string() |> String.pad_leading(2, "0")}"
endWhen NOT to Use Doctests
Skip doctests when the function:
- Touches the database -- results depend on test state
- Makes HTTP requests or calls external services
- Depends on time --
DateTime.utc_now/0, timers, TTLs - Produces random output -- UUIDs, tokens, nonces
- Has side effects -- sends emails, writes files, publishes messages
- Returns large or complex structures -- hard to read and brittle
# BAD - database dependency
@doc """
iex> MyApp.Accounts.create_user(%{email: "test@example.com"})
{:ok, %User{}}
"""
# BAD - time dependent
@doc """
iex> MyApp.Token.generate_expiring()
%{token: "abc", expires_at: ~U[2025-01-01 12:00:00Z]}
"""
# GOOD - write a regular ExUnit test instead
test "create_user/1 persists a valid user" do
assert {:ok, %User{email: "test@example.com"}} =
MyApp.Accounts.create_user(%{email: "test@example.com"})
endiex> Syntax Basics
Single-Line Expressions
Each doctest begins with iex> followed by a space and the expression. The expected result goes on the next line, unindented relative to iex>:
@doc """
iex> String.upcase("hello")
"HELLO"
"""Multi-Line Expressions
Use ...> for continuation lines. The result still follows on the next line:
@doc """
iex> %{name: "Alice", role: :admin}
...> |> MyApp.Accounts.display_name()
"Alice (admin)"
"""Multiple Examples in One Docstring
Separate independent examples with a blank line:
@doc """
iex> MyApp.Math.clamp(15, 0, 10)
10
iex> MyApp.Math.clamp(-3, 0, 10)
0
iex> MyApp.Math.clamp(5, 0, 10)
5
"""Binding Variables Across Lines
Variables bound in one iex> line carry forward within the same example block:
@doc """
iex> list = [3, 1, 4, 1, 5]
iex> Enum.sort(list)
[1, 1, 3, 4, 5]
"""Testing Error Tuples
Return error tuples directly:
@doc """
iex> MyApp.Validation.parse_age("not a number")
{:error, :invalid_integer}
iex> MyApp.Validation.parse_age("-5")
{:error, :must_be_positive}
iex> MyApp.Validation.parse_age("25")
{:ok, 25}
"""Testing Exceptions
Use ** (ExceptionModule) syntax:
@doc """
iex> MyApp.Validation.parse_age!(nil)
** (ArgumentError) expected a string, got: nil
"""The message after the exception module name is matched as a prefix, so you do not need the full message if it is long. However, the exception module must match exactly.
# Matches any FunctionClauseError regardless of message
@doc """
iex> MyApp.Math.factorial(-1)
** (FunctionClauseError)
"""Doctests with Structs
Inspect-Based Output
When a struct implements the Inspect protocol with a custom format, match against that format:
@doc """
iex> MyApp.Money.new(1099, :USD)
#MyApp.Money<$10.99 USD>
"""Default Struct Inspect
By default, structs inspect as %Module{}:
@doc """
iex> MyApp.Coordinate.origin()
%MyApp.Coordinate{x: 0, y: 0}
"""Partial Matching with Pattern Variables
When a struct has fields you cannot predict (like IDs or timestamps), avoid doctests. Write a regular test instead, or only test the fields you control:
# Instead of a doctest, use a regular test:
test "build/1 sets the correct defaults" do
coord = MyApp.Coordinate.build(%{x: 5})
assert coord.x == 5
assert coord.y == 0
endSetting Up Doctests in Test Files
Add doctest to any ExUnit test file:
defmodule MyApp.FormatTest do
use ExUnit.Case, async: true
# Run all doctests in the module
doctest MyApp.Format
# Additional unit tests
test "price_in_dollars/1 handles large values" do
assert MyApp.Format.price_in_dollars(1_000_000) == "$10000.00"
end
endYou can also place doctests in a dedicated file:
defmodule MyApp.DoctestTest do
use ExUnit.Case, async: true
doctest MyApp.Format
doctest MyApp.Math
doctest MyApp.Validation
endRunning Specific Doctests
# Run all tests in a file containing doctests
mix test test/my_app/format_test.exs
# Run a specific doctest by line number (line of the iex> prompt in source)
mix test test/my_app/format_test.exs:14Common Gotchas
Whitespace Sensitivity
The expected output must match exactly, including whitespace. Trailing spaces will cause failures that are hard to spot:
# This will FAIL if inspect output has no trailing space
@doc """
iex> inspect(%{a: 1})
"%{a: 1} "
"""Map Key Ordering
Maps with atom keys are printed in alphabetical order by inspect/1. Match that order:
# GOOD - keys in alphabetical order
@doc """
iex> MyApp.Config.defaults()
%{host: "localhost", port: 4000, scheme: :https}
"""
# BAD - keys in insertion order (will fail)
@doc """
iex> MyApp.Config.defaults()
%{scheme: :https, host: "localhost", port: 4000}
"""String Escaping
Strings containing special characters must match the inspected form:
@doc """
iex> MyApp.CSV.escape("value with \\"quotes\\"")
"\\"value with \\\\\\\"quotes\\\\\\\"\\""
"""When string escaping gets complex, skip the doctest and write a regular test for clarity.
Large or Multiline Output
If output spans many lines, it becomes brittle. Prefer regular tests:
# BAD - long output makes doctest fragile and hard to read
@doc """
iex> MyApp.Report.generate(:monthly)
%{
title: "Monthly Report",
sections: [
%{name: "Revenue", ...},
...
]
}
"""
# GOOD - test what matters in a regular test
test "generate/1 returns a report with expected sections" do
report = MyApp.Report.generate(:monthly)
assert report.title == "Monthly Report"
assert length(report.sections) == 3
endOpaque Types
You cannot match on the internals of an @opaque type in a doctest. Instead, show usage patterns:
@doc """
iex> conn = MyApp.Connection.open("localhost", 5432)
iex> is_struct(conn, MyApp.Connection)
true
"""