Pitch: como testar OTP por SMS de ponta a ponta em Python sem seu chip
Transparência: Trabalho com a HeroSMS, o provedor de números virtuais usado nos exemplos de teste de OTP por SMS abaixo. Texto escrito com ferramentas de IA; código e endpoints conferidos com a referência da API da HeroSMS.
Cenário comum: faltam dois dias para o release e o seu cadastro envia um código de verificação por SMS. Os testes unitários mockam o client de SMS e está tudo verde. Aí alguém do QA, testando com um número de outro país, avisa que o código não chegou, ou chegou depois que a tela de verificação expirou. O CI não pegou isso porque nunca mandou um SMS de verdade.
Abaixo, um teste de OTP por SMS de ponta a ponta em Python, sem depender do chip de ninguém do time. O script aluga um número real por uma API de verificação por SMS, o seu app manda o código, o script lê o OTP (por polling ou webhook), devolve o código ao app e faz a limpeza, com reembolso quando nenhum SMS chega.
Mock, número de teste ou número real?
Use a ferramenta mais barata que responde à sua pergunta:
- Mock do client de SMS. Para testes unitários e a maioria dos de integração. Rápido e determinístico, mas prova a sua lógica, não a entrega.
- Credenciais de teste da Twilio. Com elas, a Twilio não cobra, não entrega em telefones reais nem dispara status callbacks, e o número "mágico"
+15005550006comoFrompassa na validação. Servem para checar o código da requisição, mas o Verify não está entre os recursos que aceitam essas credenciais (documentação). - Números de teste do Firebase Auth. Nas configurações do provedor de login por telefone, dá para cadastrar até 10 números fictícios, cada um com código fixo de 6 dígitos. Nenhum SMS é enviado, a cota não é consumida e execuções repetidas não são limitadas (documentação). Bom para testes de UI.
- Números reais. Respondem à pergunta "o SMS de verdade chega, e o fluxo inteiro aceita o código?". Use num smoke test em staging antes do release, depois de trocar de provedor de SMS ou de template, ou ao lançar em um país novo.
Custo e frequência: não em todo commit
Execuções com número real custam dinheiro e levam minutos. Rode esse teste de forma agendada (um job noturno, um passo antes do release), não em todo commit. Guarde a chave da API em um secret do CI, nunca no repositório, e defina HEROSMS_MAX_PRICE para limitar o preço por número.
Setup
Você compra uma ativação: um número reservado para um serviço em um país. Precisa de uma chave da API da HeroSMS e do requests:
pip install requests
export HEROSMS_API_KEY="sua-chave"
export HEROSMS_COUNTRY=16 # ID do país; 16 = Reino Unido
export APP_SIGNUP_URL="<seu endpoint de cadastro em staging>"
export APP_VERIFY_URL="<seu endpoint de verificação em staging>"
# opcional: HEROSMS_SERVICE (padrão "ot"), HEROSMS_MAX_PRICE, OTP_TIMEOUT
A API REST fica em https://hero-sms.com/api/v1 e espera o header Authorization: ApiKey <token>. Os erros vêm com title e details. O fluxo usa cinco endpoints:
| Etapa | Endpoint |
|---|---|
| Ver preço e estoque | GET /activations/offers/sms?services=ot&countries=16 |
| Comprar um número | POST /activations |
| Ler o OTP | GET /activations/{activationId}/otp/last |
| Código recebido, encerrar | POST /activations/{activationId}/finish |
| Sem código, cancelar e reembolsar | DELETE /activations/{activationId} |
Para o seu app, use o serviço ot ("Any other", qualquer outro) e o país onde vai lançar; a documentação da API lista todos os códigos e IDs de país.
O script em Python: receber o código de verificação por SMS via API
# otp_e2e.py - pip install requests
import os
import re
import sys
import time
import requests
API = "https://hero-sms.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"ApiKey {os.environ['HEROSMS_API_KEY']}"
CLOSED = {"ACTIVATION_NOT_ACTIVE", "CANCELED", "FINISHED", "REFUNDED"}
class HeroSMSError(RuntimeError):
def __init__(self, resp):
try:
body = resp.json()
except ValueError:
body = {}
if not isinstance(body, dict):
body = {}
try:
self.retry_after = int(resp.headers.get("Retry-After") or 0)
except ValueError: # uma data HTTP em vez de segundos
self.retry_after = 0
self.status = resp.status_code
self.title = body.get("title", "")
details = body.get("details", resp.text[:200])
super().__init__(f"HTTP {self.status} {self.title}: {details}")
def call(method, path, **kwargs):
r = session.request(method, API + path, timeout=15, **kwargs)
if r.status_code >= 400:
raise HeroSMSError(r)
return r.json() if r.content else None
def check_offer(service, country):
try:
resp = call("GET", "/activations/offers/sms",
params={"services": service, "countries": country})
except HeroSMSError as e:
if e.status != 404: # 404 = OFFER_NOT_FOUND
raise
resp = None
data = (resp or {}).get("data") or {}
offer = (data.get(service) or {}).get(str(country))
if not offer or offer["counts"]["total"] == 0:
sys.exit(f"Sem números de {service} no país {country} agora")
return offer
def buy_number(service, country, max_price=None):
body = {"service": service, "country": country,
"amount": 1, "verificationType": "sms"}
if max_price is not None:
body["maxPrice"] = max_price
return call("POST", "/activations", json=body)["data"][0]
def wait_for_otp(activation_id, timeout=300, interval=5):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
resp = call("GET", f"/activations/{activation_id}/otp/last")
otp = (resp or {}).get("data") or {}
if otp.get("smsCode") or otp.get("smsText"):
return otp
except HeroSMSError as e:
if e.status == 429:
time.sleep(e.retry_after or interval)
continue
# 400/404 = ainda sem SMS, exceto com a ativação encerrada
if e.status not in (400, 404) or e.title in CLOSED:
raise
time.sleep(interval)
return None
def release(activation_id, got_sms):
try:
if got_sms:
call("POST", f"/activations/{activation_id}/finish")
else:
call("DELETE", f"/activations/{activation_id}") # cancela + reembolsa
except (HeroSMSError, requests.RequestException) as e:
print(f"Limpeza de {activation_id} falhou: {e}", file=sys.stderr)
# Troque pelas chamadas do seu app (API de staging ou Playwright).
def trigger_signup(phone):
r = requests.post(os.environ["APP_SIGNUP_URL"],
json={"phone": phone}, timeout=15)
r.raise_for_status()
def verify_code(phone, code):
r = requests.post(os.environ["APP_VERIFY_URL"],
json={"phone": phone, "code": code}, timeout=15)
r.raise_for_status() # adicione asserts sobre o corpo da resposta
def main():
service = os.getenv("HEROSMS_SERVICE", "ot") # "ot" = "Any other"
country = int(os.environ["HEROSMS_COUNTRY"]) # 16 = Reino Unido
max_price = os.getenv("HEROSMS_MAX_PRICE")
offer = check_offer(service, country)
print(f"{offer['counts']['total']} números, "
f"preço {offer['prices']['default']}")
activation = buy_number(service, country,
float(max_price) if max_price else None)
act_id = activation["id"]
otp = None
try:
phone = f"+{activation['phone']}"
print(f"Ativação {act_id}: {phone}, "
f"expira em {activation.get('expiredAt')}")
trigger_signup(phone)
timeout = int(os.getenv("OTP_TIMEOUT", "300"))
otp = wait_for_otp(act_id, timeout=timeout)
if otp is None:
raise TimeoutError(f"Nenhum SMS para {phone} em {timeout}s")
match = re.search(r"\b\d{4,8}\b", otp.get("smsText") or "")
code = otp.get("smsCode") or (match.group() if match else None)
if not code:
raise AssertionError("SMS sem código; confira o seu template")
print(f"SMS de {otp.get('phoneFrom')}, código {code[:1]}***")
verify_code(phone, code)
finally:
release(act_id, got_sms=otp is not None)
if __name__ == "__main__":
main()
O que está acontecendo:
POST /activationsdevolve um arraydata(amountvai até 10 para testes em paralelo). Ophonevem sem o+; remova o prefixocountryPhoneCodese o seu formulário tem um seletor de país separado. Uma ativação padrão dura 20 minutos (vejaexpiredAt), então mantenha oOTP_TIMEOUTbem abaixo disso.otp/lastdevolvesmsCode(código já extraído) esmsText(mensagem completa); qualquer um pode virnull. Como a referência não documenta a resposta antes do primeiro SMS, o script trata 400, 404 edatavazio como "ainda não". No 429 ele espera e repete; com título de ativação encerrada (comoACTIVATION_NOT_ACTIVE) ou qualquer outro erro, para. Um 404 por ID errado vai parecer timeout. SemsmsCode, uma regex lê o texto; SMS sem código derruba o teste em vez de virar timeout.verify_codeenvia o código ao app, o que transforma um teste de entrega em um teste de fluxo. Faça assert do que "cadastro concluído" significa no seu app.- A limpeza roda no
finally. Sem SMS, oDELETEcancela e reembolsa. Depois do OTP, uma ativação comum não pode ser cancelada, então o script chamafinish(sem reembolso). A documentação diz que o número não pode ser cancelado nos primeiros 2 minutos (EARLY_CANCEL_DENIED): se o cadastro falhar em segundos, o cancelamento não sai na hora. Orelease()registra isso e os erros de rede sem esconder a falha original; uma ativação que nunca recebe SMS é reembolsada automaticamente. trigger_signupeverify_codeusamrequestspuro, não a sessão da API, então a sua chave da HeroSMS nunca chega ao seu app. Se o app recusar um número,POST /activations/{activationId}/replacetroca por outro, ephoneExceptionno corpo da compra exclui prefixos de antemão.
Receber o OTP por webhook em vez de polling
Polling resolve para um teste noturno. Para muitas execuções em paralelo, deixe o provedor empurrar as mensagens. O webhook sms-incoming faz POST de um JSON para até três URLs HTTPS configuradas no perfil da conta.
# webhook.py - pip install flask
from flask import Flask, request
app = Flask(__name__)
HEROSMS_IPS = {"84.32.223.53", "185.138.88.87"}
latest = {} # activationId -> SMS mais novo (em produção: Redis ou um banco)
@app.post("/hooks/herosms/sms")
def sms_incoming():
# Chamado só via HTTPS: rode atrás do seu proxy TLS e pegue o IP
# do cliente no header do proxy, e não no remote_addr.
if request.remote_addr not in HEROSMS_IPS:
return "", 403
event = request.get_json(silent=True) or {}
act_id = event.get("activationId")
if act_id is not None:
prev = latest.get(act_id) or {}
# Retries repetem o SMS; reenvios são mais novos. Guarde o mais novo.
if (event.get("receivedAt") or "") >= (prev.get("receivedAt") or ""):
latest[act_id] = event
return "", 200 # em até 3 segundos
Pontos de atenção:
- Responda 200 em até 3 segundos. Qualquer outra resposta conta como falha, e o webhook é reenviado pelo menos 7 vezes, a cada 20–30 segundos; mantenha o handler idempotente e devolva 200 também para duplicatas.
- O payload tem
id,activationId,phoneFrom,service,text,code,countryereceivedAt. Os nomes diferem do REST, que usasmsCodeesmsText. - A documentação publica esses IPs de origem, mas não uma assinatura de requisição: a allowlist é a sua verificação. O teste passa a ler o seu próprio storage; a limpeza continua igual. Mantenha o polling como fallback: um staging atrás de VPN muitas vezes não recebe webhooks.
Uso responsável: termos de uso e LGPD
- Teste só apps que são seus ou que você tem autorização para testar. Usar números alugados para driblar limites de cadastro de outros serviços, criar contas em massa ou burlar regras de verificação normalmente viola os termos desses serviços.
- O número alugado é seu só durante a janela da ativação. Nunca o vincule a uma conta que você vá precisar recuperar, e apague as contas de teste criadas com ele.
- LGPD: nas contas de teste, use só dados fictícios; nada de copiar nome, CPF ou telefone de clientes reais para staging. Telefone e conteúdo de SMS podem identificar uma pessoa, então trate como dado pessoal: guarde o mínimo, pelo menor tempo possível, e apague junto com as contas de teste.
- Trate OTPs nos logs como segredos: quem lê os logs do CI vê o código enquanto ele vale, por isso o script mascara.
Resumindo
Mock e números de teste para o CI do dia a dia; um teste com número real, agendado, para pegar problemas de entrega antes do usuário.
Os exemplos rodam na HeroSMS. A referência completa, com o schema do webhook, está na documentação da API.
E vocês, como testam OTP por SMS: só mock, números de teste do Firebase, ou alguém já roda número real no pipeline?